@lingfan-engine/core 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/contracts/guard.ts +9 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/media/entities.ts +85 -3
- package/src/contracts/media/ports.ts +48 -0
- package/src/contracts/param-direction.ts +103 -0
- package/src/contracts/runtime/column-summary.ts +24 -0
- package/src/contracts/runtime/controls.ts +49 -0
- package/src/contracts/runtime/entities.ts +21 -0
- package/src/contracts/runtime/index.ts +2 -0
- package/src/contracts/runtime/reader.ts +41 -1
- package/src/contracts/runtime/state-keys.ts +70 -2
- package/src/contracts/runtime/timer.ts +8 -1
- package/src/contracts/save.ts +18 -1
- package/src/contracts/shell.ts +26 -0
- package/src/contracts/story/entities.ts +23 -8
- package/src/data/format/column.ts +33 -4
- package/src/data/format/validate/dialog.ts +32 -3
- package/src/data/index.ts +1 -0
- package/src/data/project/assemble.ts +37 -6
- package/src/data/project/conflict.ts +4 -3
- package/src/data/project/diff.ts +3 -3
- package/src/data/project/index.ts +6 -1
- package/src/data/project/naming.ts +11 -5
- package/src/data/project/serialize.ts +49 -16
- package/src/data/text/entry.ts +17 -9
- package/src/data/text/generate/command/dialog.ts +15 -3
- package/src/data/text/generate/command/index.ts +2 -1
- package/src/data/text/generate/command/media.ts +33 -2
- package/src/data/text/generate/command/ui.ts +29 -0
- package/src/data/text/generate/command.ts +10 -0
- package/src/data/text/literals.ts +140 -0
- package/src/data/text/parse/statement/dialog.ts +19 -0
- package/src/data/text/parse/statement/flow.ts +1 -1
- package/src/data/text/parse/statement/index.ts +2 -1
- package/src/data/text/parse/statement/media.ts +91 -2
- package/src/data/text/parse/statement/ui.ts +47 -0
- package/src/data/text/parse/statement.ts +9 -0
- package/src/index.ts +8 -1
- package/src/runtime/dispatch.ts +24 -0
- package/src/runtime/engine/columns.ts +31 -0
- package/src/runtime/engine/forwarders.ts +7 -4
- package/src/runtime/engine/game-state.ts +29 -0
- package/src/runtime/engine/index.ts +2 -0
- package/src/runtime/engine/state.ts +13 -1
- package/src/runtime/engine.ts +46 -21
- package/src/runtime/expr/builtins.ts +1 -1
- package/src/runtime/expr/evaluate.ts +1 -1
- package/src/runtime/expr/index.ts +1 -1
- package/src/runtime/expr/interpolate.ts +1 -1
- package/src/runtime/expr/parser/cursor.ts +1 -1
- package/src/runtime/history/index.ts +1 -0
- package/src/runtime/history/rollback.ts +23 -8
- package/src/runtime/index.ts +9 -3
- package/src/runtime/internal/context.ts +25 -2
- package/src/runtime/internal/helpers.ts +18 -0
- package/src/runtime/internal/index.ts +9 -1
- package/src/runtime/internal/session.ts +11 -0
- package/src/runtime/internal/waiting.ts +51 -3
- package/src/runtime/opRegistry.ts +7 -0
- package/src/runtime/ops/dialog.ts +2 -11
- package/src/runtime/ops/flow.ts +1 -1
- package/src/runtime/ops/index.ts +11 -1
- package/src/runtime/ops/interaction.ts +14 -1
- package/src/runtime/ops/media/audio.ts +230 -0
- package/src/runtime/ops/media/index.ts +18 -0
- package/src/runtime/ops/media/instances.ts +312 -0
- package/src/runtime/ops/media/notify.ts +32 -0
- package/src/runtime/ops/media/shared.ts +133 -0
- package/src/runtime/ops/media/video.ts +118 -0
- package/src/runtime/ops/ui.ts +95 -0
- package/src/runtime/paramDirection.ts +35 -0
- package/src/runtime/save/export.ts +52 -6
- package/src/runtime/save/import.ts +21 -6
- package/src/runtime/scope.ts +17 -0
- package/src/runtime/session/index.ts +21 -0
- package/src/runtime/session/pause.ts +114 -0
- package/src/runtime/{waitingOps.ts → session/waitingOps.ts} +1 -1
- package/src/runtime/shell.ts +68 -0
- package/src/runtime/state/index.ts +2 -1
- package/src/runtime/state/reader.ts +47 -1
- package/src/runtime/state/writer.ts +1 -1
- package/src/shared/audio.ts +106 -0
- package/src/shared/guards.ts +38 -4
- package/src/shared/index.ts +3 -0
- package/src/shared/mounts.ts +42 -0
- package/src/shared/ui-name.ts +41 -0
- package/src/runtime/ops/media.ts +0 -274
- /package/src/runtime/{resolver.ts → expr/resolver.ts} +0 -0
- /package/src/runtime/{stateContract.ts → state/stateContract.ts} +0 -0
package/src/contracts/save.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { ColumnCoordinate, StoryCommand } from "./story/index.ts";
|
|
9
9
|
|
|
10
|
-
/** 存档载荷 v1
|
|
10
|
+
/** 存档载荷 v1(列级作用域随档;块级作用域由坐标重放重建) */
|
|
11
11
|
export interface SaveDataV1 {
|
|
12
12
|
formatVersion: 1;
|
|
13
13
|
storyId: string;
|
|
@@ -16,6 +16,15 @@ export interface SaveDataV1 {
|
|
|
16
16
|
/** 等待点命令坐标:读档时从该命令重放,重建等待画面 */
|
|
17
17
|
coord: ColumnCoordinate;
|
|
18
18
|
state: [string, unknown][];
|
|
19
|
+
/**
|
|
20
|
+
* 存档时刻的**列级**作用域变量(`let`/`local` 声明在列顶层的那一批)。
|
|
21
|
+
*
|
|
22
|
+
* 为什么必须随档:读档从 `coord` 重放,而列级声明都在 `coord` **之前**——
|
|
23
|
+
* 那些命令不会重跑,光靠重放重建不出来。块级(`if`/`while`/函数体里的)
|
|
24
|
+
* 不在这里:存档坐标会回退到块的控制命令,重放会重入块体从头执行,能重建。
|
|
25
|
+
* 旧档无此字段 = 空列级层。
|
|
26
|
+
*/
|
|
27
|
+
scope?: [string, unknown][];
|
|
19
28
|
rngState: number;
|
|
20
29
|
functions: [string, { params: string[]; body: StoryCommand[] }][];
|
|
21
30
|
/** 检查点序列随档,读档后可继续回溯 */
|
|
@@ -51,6 +60,14 @@ export interface SaveDataV1 {
|
|
|
51
60
|
coord: ColumnCoordinate;
|
|
52
61
|
state: [string, unknown][];
|
|
53
62
|
rngState: number;
|
|
63
|
+
/**
|
|
64
|
+
* 该检查点所在列的列级作用域。
|
|
65
|
+
*
|
|
66
|
+
* 每个检查点各存一份:回溯到档内任意一步都要能恢复它那一刻的列级变量,
|
|
67
|
+
* 只存档尾那一份是不够的(回溯目标可以是档内任何一步)。
|
|
68
|
+
* 该检查点的列级层为空、或旧档无此字段 ⇒ 按空层重建。
|
|
69
|
+
*/
|
|
70
|
+
scope?: [string, unknown][];
|
|
54
71
|
}>;
|
|
55
72
|
}
|
|
56
73
|
|
package/src/contracts/shell.ts
CHANGED
|
@@ -10,10 +10,31 @@
|
|
|
10
10
|
* Android 16 起 sw≥600dp 大屏忽略方向限制(游戏凭 `appCategory="game"` 豁免);
|
|
11
11
|
* iOS 必须在 plist 内声明过该方向且未开启多任务(iPad)才可能生效。
|
|
12
12
|
*/
|
|
13
|
+
import type { AudioChannel } from "./media/entities.ts";
|
|
13
14
|
|
|
14
15
|
/** 方向模式:auto = 跟随系统与用户自动旋转;portrait/landscape = 锁定方向 */
|
|
15
16
|
export type OrientationMode = "auto" | "portrait" | "landscape";
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* 工程级音频配置(`project.json` 的 `shell.audio`;解析见 `runtime/shell.ts`)。
|
|
20
|
+
*
|
|
21
|
+
* **音量的第二级**:命令没写 `volume=` 时取这里,再没有才是内建 1。
|
|
22
|
+
* 玩家偏好不在这里——偏好是最外层的乘算(`op 音量 × 通道偏好`),
|
|
23
|
+
* 顺序不能反:工程默认是作者写的、偏好是玩家设的,把作者默认塞进偏好层
|
|
24
|
+
* 会让「换个工程,玩家设置被这个工程的默认值改掉」。
|
|
25
|
+
*
|
|
26
|
+
* 解析结果是**完整表**(四个通道各一个数),缺声明的通道回 1。
|
|
27
|
+
*/
|
|
28
|
+
export interface AudioConfig {
|
|
29
|
+
/** 各通道的工程默认音量(0..1) */
|
|
30
|
+
readonly volumes: Readonly<Record<AudioChannel, number>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 内建默认音频配置:四个通道都是 1(与「未声明 = 满音量」的既有行为一致) */
|
|
34
|
+
export const DEFAULT_AUDIO_CONFIG: AudioConfig = {
|
|
35
|
+
volumes: { bgm: 1, se: 1, ambient: 1, voice: 1 },
|
|
36
|
+
};
|
|
37
|
+
|
|
17
38
|
/**
|
|
18
39
|
* 工程级壳配置(project.json `shell` 段):作者声明的作品形态。
|
|
19
40
|
* 缺省 = auto(跟随系统);玩家偏好可覆盖(偏好与存档分离)。
|
|
@@ -21,6 +42,11 @@ export type OrientationMode = "auto" | "portrait" | "landscape";
|
|
|
21
42
|
export interface ProjectShellConfig {
|
|
22
43
|
/** 作品默认方向(缺省 = auto) */
|
|
23
44
|
orientation?: OrientationMode;
|
|
45
|
+
/** 作者声明的通道默认音量(缺省 = 四通道皆 1) */
|
|
46
|
+
audio?: {
|
|
47
|
+
/** 通道 → 0..1;未列出的通道回内建 1 */
|
|
48
|
+
volumes?: Partial<Record<AudioChannel, number>>;
|
|
49
|
+
};
|
|
24
50
|
}
|
|
25
51
|
|
|
26
52
|
/**
|
|
@@ -25,16 +25,19 @@ export interface StoryCommand {
|
|
|
25
25
|
*
|
|
26
26
|
* **与 `kind` 正交,两根轴各管一件事**:
|
|
27
27
|
* - `kind`(scene/flow)= **内容形态**:有没有 `elements[]` / `commands[]`
|
|
28
|
-
* - `type`(game/
|
|
28
|
+
* - `type`(game/ui)= **运行语义**:进不进历史与存档
|
|
29
29
|
*
|
|
30
|
-
*
|
|
30
|
+
* 两态语义:
|
|
31
31
|
* - `game`:实际游戏场景 —— **存档、进历史、建立检查点**
|
|
32
|
-
* - `
|
|
33
|
-
*
|
|
32
|
+
* - `ui`:界面层(菜单 / 标题 / 设置 / 覆盖层 / 弹窗)—— 不存档、不进历史、不建检查点
|
|
33
|
+
*
|
|
34
|
+
* **两者在运行期没有行为差异**:唯一的判定只有「进不进历史与存档」。所以不拆成
|
|
35
|
+
* 「菜单」「界面」两个类型——那点区别不存在于引擎里,多一个取值只会让每个消费者
|
|
36
|
+
* 都得再判一次。界面内部怎么分类(标题页 / 设置页 / 弹窗)归页面层自己表达。
|
|
34
37
|
*
|
|
35
38
|
* **缺省 = `game`** ⇒ 既有工程零改动。
|
|
36
39
|
*/
|
|
37
|
-
export type SceneType = "game" | "
|
|
40
|
+
export type SceneType = "game" | "ui";
|
|
38
41
|
|
|
39
42
|
/** 两类列:scene(空间层)与 flow(纯流程) */
|
|
40
43
|
export interface StoryColumn {
|
|
@@ -42,8 +45,8 @@ export interface StoryColumn {
|
|
|
42
45
|
kind: "scene" | "flow";
|
|
43
46
|
/**
|
|
44
47
|
* 运行语义类型(**缺省 `game`**)。
|
|
45
|
-
*
|
|
46
|
-
* **与 `kind` 正交**:`kind: "flow" + type: "
|
|
48
|
+
* 决定该列是否进历史 / 存档 / 检查点。
|
|
49
|
+
* **与 `kind` 正交**:`kind: "flow" + type: "ui"`(纯流程的界面层)是合法组合。
|
|
47
50
|
*/
|
|
48
51
|
type?: SceneType;
|
|
49
52
|
/**
|
|
@@ -66,6 +69,18 @@ export interface StoryColumn {
|
|
|
66
69
|
* 不设端口、不进存档——只是每列的排版事实。
|
|
67
70
|
*/
|
|
68
71
|
font?: string;
|
|
72
|
+
/**
|
|
73
|
+
* **列级 UI 声明**(纯展示元数据):本列引用的 UI 名字,名字 = `src/ui/<名>/` 的目录名。
|
|
74
|
+
*
|
|
75
|
+
* 语义要点:
|
|
76
|
+
* - **静态声明是「告知」不是「加载」**:产物里只有名字,UI 本体住 `src/ui/**`,
|
|
77
|
+
* 由宿主按名字预加载;引擎不读、不编译、不拷贝那些文件。
|
|
78
|
+
* - **与 `elements` 是两个概念**:`elements` 是舞台元素,进 `SYS.elements`,
|
|
79
|
+
* 有 show / hide / animate;`uis` 是挂载界面,进 `SYS.mounts`,只有显隐。
|
|
80
|
+
* - **未引用不写**:没有 UI 的列不带该字段(与 `font` 同规则)。
|
|
81
|
+
* - **顺序确定**:按名字码点序去重,构建期即定序,消费方不必再排。
|
|
82
|
+
*/
|
|
83
|
+
uis?: readonly string[];
|
|
69
84
|
/** scene 专有:舞台元素声明(类型与属性全集 fail-closed)。
|
|
70
85
|
* 进入列时由引擎装载为 `SYS.elements`(声明式空间层,不走命令流) */
|
|
71
86
|
elements?: ElementNode[];
|
|
@@ -109,7 +124,7 @@ export interface Story {
|
|
|
109
124
|
* 纯修复与文档不进位。**禁止**从 git 提交数、时间戳或产物哈希派生——那会让产物
|
|
110
125
|
* 不再可复现,破坏构建的「零差量」幂等守卫。
|
|
111
126
|
*/
|
|
112
|
-
export const CAPABILITY_VERSION = "0.
|
|
127
|
+
export const CAPABILITY_VERSION = "0.10.0";
|
|
113
128
|
|
|
114
129
|
/** 工程清单(project.json):结构化/原子化多文件工程的组装契约 */
|
|
115
130
|
export interface ProjectManifest {
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
import { isPlainObject } from "../../shared/index.ts";
|
|
10
10
|
import {
|
|
11
11
|
isSceneType,
|
|
12
|
+
isUiName,
|
|
13
|
+
normalizeSceneType,
|
|
14
|
+
normalizeUiNames,
|
|
12
15
|
type ElementNode,
|
|
13
16
|
type SceneType,
|
|
14
17
|
type StoryColumn,
|
|
@@ -38,8 +41,8 @@ export type ColumnShapeDefect =
|
|
|
38
41
|
/**
|
|
39
42
|
* 列对象形状判定(缺省校验 `type`)。
|
|
40
43
|
*
|
|
41
|
-
* `type`(运行语义:game/
|
|
42
|
-
* 非法值 fail-closed(不静默当 game
|
|
44
|
+
* `type`(运行语义:game/ui)**缺省合法 = game**,但**给了就必须合法**——
|
|
45
|
+
* 非法值 fail-closed(不静默当 game,否则作者以为设了界面层其实是剧情,
|
|
43
46
|
* 那种错要等存档/回溯出问题才发现,代价极高)。
|
|
44
47
|
*
|
|
45
48
|
* 已经另行校验过 `type` 的调用点可传 `checkType: false`,避免同一处报两遍。
|
|
@@ -98,7 +101,7 @@ export function parseColumn(
|
|
|
98
101
|
);
|
|
99
102
|
} else if (shape.reason === "bad-type") {
|
|
100
103
|
issues.push(
|
|
101
|
-
`${at}.type 必须为 "game" / "
|
|
104
|
+
`${at}.type 必须为 "game" / "ui",收到 ${JSON.stringify(shape.received)}`,
|
|
102
105
|
);
|
|
103
106
|
} else if (shape.reason === "flow-without-commands") {
|
|
104
107
|
issues.push(`${at}(flow)必须有 commands 数组`);
|
|
@@ -107,7 +110,12 @@ export function parseColumn(
|
|
|
107
110
|
}
|
|
108
111
|
return null;
|
|
109
112
|
}
|
|
110
|
-
|
|
113
|
+
// `type` 已在上面按形状判定过(只接受 `game` / `ui`,其余含旧拼写 `menu` 一律
|
|
114
|
+
// fail-closed 报错),这里只做「缺席 = 不声明」与合法值的归一。归一仍然要调,
|
|
115
|
+
// 因为它是读侧兜底:非法值到不了这一步,但缺省与空值的收敛口径与它同一处。
|
|
116
|
+
// 原拼写要不要留(编辑器展示「菜单」还是「界面」)由编辑器自己收,不进故事树。
|
|
117
|
+
const type: SceneType | undefined =
|
|
118
|
+
raw.type === undefined ? undefined : normalizeSceneType(raw.type);
|
|
111
119
|
const kind = raw.kind as "scene" | "flow";
|
|
112
120
|
// 列级字体:可选元数据,非空字符串才收(注册表校验在构建期/渲染层闸门,解析只管形状)
|
|
113
121
|
let font: string | undefined;
|
|
@@ -120,6 +128,26 @@ export function parseColumn(
|
|
|
120
128
|
}
|
|
121
129
|
font = raw.font;
|
|
122
130
|
}
|
|
131
|
+
// 列级 UI 声明:可选元数据,字符串数组且每元素是合法 UI 名(目录名形态)。
|
|
132
|
+
// 读入即归一(去重 + 码点序):产物字节要由数据决定,不能由书写顺序决定。
|
|
133
|
+
// 空数组与缺省等价(`undefined`)——「没有 UI 的列不带该字段」是同一条规则。
|
|
134
|
+
let uis: readonly string[] | undefined;
|
|
135
|
+
if (raw.uis !== undefined) {
|
|
136
|
+
if (!Array.isArray(raw.uis)) {
|
|
137
|
+
issues.push(`${at}.uis 必须为字符串数组(元素是 UI 目录名)`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
for (const [j, name] of raw.uis.entries()) {
|
|
141
|
+
if (!isUiName(name)) {
|
|
142
|
+
issues.push(
|
|
143
|
+
`${at}.uis[${j}] 不是合法 UI 名:收到 ${JSON.stringify(name)}(小写字母开头,其后小写字母 / 数字 / \`_\` / \`-\`,总长 1..32)`,
|
|
144
|
+
);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const normalized = normalizeUiNames(raw.uis as string[]);
|
|
149
|
+
if (normalized.length > 0) uis = normalized;
|
|
150
|
+
}
|
|
123
151
|
if (kind === "flow") {
|
|
124
152
|
if (!Array.isArray(raw.commands)) {
|
|
125
153
|
issues.push(`${at}(flow)必须有 commands 数组`);
|
|
@@ -165,6 +193,7 @@ export function parseColumn(
|
|
|
165
193
|
kind,
|
|
166
194
|
type,
|
|
167
195
|
font,
|
|
196
|
+
uis,
|
|
168
197
|
// `sourcePath` **刻意不解析**:它是编辑期记账(由组装器从实际文件路径回填),
|
|
169
198
|
// 故事文件里不该有这个键——写进去也无效(防止自指:文件描述自己的位置)。
|
|
170
199
|
elements: raw.elements as ElementNode[] | undefined,
|
|
@@ -6,6 +6,37 @@ import { requireNonEmptyString } from "../guards.ts";
|
|
|
6
6
|
import { validateInstanceZ } from "./shape.ts";
|
|
7
7
|
import { isPlainObject } from "../../../shared/index.ts";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* `input.options` 的候选快捷值:`{ text, value }[]`,每项两字段都必填非空。
|
|
11
|
+
*
|
|
12
|
+
* 形态选 `text` + `value` 而非 `string[]` 或 menu 的 `{text, target}`:
|
|
13
|
+
* - `text` 进翻译表(多语言只译显示文本),`value` 原样写进 `store`——若用 `string[]`,
|
|
14
|
+
* 译文会被写进变量,多语言环境下故事逻辑跟着语言漂移;
|
|
15
|
+
* - 与 `menu` 的 `{text, target}` 区分开:input 的候选是「替玩家填一个值」,
|
|
16
|
+
* 不是「跳到另一列」,两者语义不同,不共用字段名以免误用。
|
|
17
|
+
*
|
|
18
|
+
* 缺省(字段不存在)= 无候选,只有输入框;空数组与 menu 不同**不算错**(等于没写)。
|
|
19
|
+
*/
|
|
20
|
+
function validateInputOptions(
|
|
21
|
+
options: unknown,
|
|
22
|
+
at: string,
|
|
23
|
+
issues: string[],
|
|
24
|
+
): void {
|
|
25
|
+
if (options === undefined) return;
|
|
26
|
+
if (!Array.isArray(options)) {
|
|
27
|
+
issues.push(`${at} 必须为数组`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const [i, option] of options.entries()) {
|
|
31
|
+
if (!isPlainObject(option)) {
|
|
32
|
+
issues.push(`${at}[${i}] 必须为对象`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
requireNonEmptyString(option.text, `${at}[${i}].text`, issues);
|
|
36
|
+
requireNonEmptyString(option.value, `${at}[${i}].value`, issues);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
9
40
|
/**
|
|
10
41
|
* 校验本族 op 的负载(7 个 op:`say` `assert` `guard` `notify` `wait` `pause` `input`)。
|
|
11
42
|
*
|
|
@@ -59,9 +90,7 @@ export function validateDialogCommand(
|
|
|
59
90
|
case "input":
|
|
60
91
|
requireNonEmptyString(cmd.prompt, `${at}.prompt`, issues);
|
|
61
92
|
requireNonEmptyString(cmd.store, `${at}.store`, issues);
|
|
62
|
-
|
|
63
|
-
issues.push(`${at}.options 选项式输入暂未实现(fail-closed)`);
|
|
64
|
-
}
|
|
93
|
+
validateInputOptions(cmd.options, `${at}.options`, issues);
|
|
65
94
|
validateInstanceZ(cmd, at, issues); // input 的实例 z(choices 层)
|
|
66
95
|
break;
|
|
67
96
|
}
|
package/src/data/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { isPlainObject } from "../../shared/index.ts";
|
|
1
|
+
import { compareOrdinal, isPlainObject } from "../../shared/index.ts";
|
|
2
2
|
import {
|
|
3
|
+
AUDIO_CHANNELS,
|
|
3
4
|
isOrientationMode,
|
|
4
5
|
type DegradedOpen,
|
|
5
6
|
type Story,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
StoryFormatError,
|
|
13
14
|
} from "../format/index.ts";
|
|
14
15
|
import { ProjectAssemblyError } from "./error.ts";
|
|
15
|
-
import {
|
|
16
|
+
import { isDotPath, MANIFEST_FILE, STORIES_DIR } from "./naming.ts";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* 工程组装:project.json + Stories/** → Story。
|
|
@@ -37,7 +38,7 @@ export function synthesizeDegradedManifest(
|
|
|
37
38
|
const ids: string[] = [];
|
|
38
39
|
for (const path of [...storyTexts.keys()]
|
|
39
40
|
.filter((p) => !isDotPath(p))
|
|
40
|
-
.sort(
|
|
41
|
+
.sort(compareOrdinal)) {
|
|
41
42
|
try {
|
|
42
43
|
const story = parseStoryFile(storyTexts.get(path) ?? "", path);
|
|
43
44
|
for (const column of story.columns) ids.push(column.id);
|
|
@@ -118,13 +119,43 @@ export function assembleProject(
|
|
|
118
119
|
if (!isPlainObject(manifest.shell)) {
|
|
119
120
|
issues.push("project.json: shell 必须为对象");
|
|
120
121
|
} else {
|
|
121
|
-
const
|
|
122
|
-
|
|
122
|
+
const shell = manifest.shell as {
|
|
123
|
+
orientation?: unknown;
|
|
124
|
+
audio?: unknown;
|
|
125
|
+
};
|
|
126
|
+
const orientation = shell.orientation;
|
|
123
127
|
if (orientation !== undefined && !isOrientationMode(orientation)) {
|
|
124
128
|
issues.push(
|
|
125
129
|
`project.json: shell.orientation 必须为 auto|portrait|landscape,收到 ${JSON.stringify(orientation)}`,
|
|
126
130
|
);
|
|
127
131
|
}
|
|
132
|
+
// 工程默认音量:形状错即拒绝;逐个通道只认有限数字(越界钳制,不拒绝)。
|
|
133
|
+
// 与 resolveAudioConfig 同口径——那里是解析(钳制后采纳),这里是拒绝形状错误,
|
|
134
|
+
// 两处若口径不同,作者会看到「构建通过但音量不对」。
|
|
135
|
+
if (shell.audio !== undefined) {
|
|
136
|
+
if (!isPlainObject(shell.audio)) {
|
|
137
|
+
issues.push("project.json: shell.audio 必须为对象");
|
|
138
|
+
} else {
|
|
139
|
+
const volumes = (shell.audio as { volumes?: unknown }).volumes;
|
|
140
|
+
if (volumes !== undefined) {
|
|
141
|
+
if (!isPlainObject(volumes)) {
|
|
142
|
+
issues.push(
|
|
143
|
+
"project.json: shell.audio.volumes 必须为对象(键为音频通道)",
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
for (const channel of AUDIO_CHANNELS) {
|
|
147
|
+
const raw = (volumes as Record<string, unknown>)[channel];
|
|
148
|
+
if (raw === undefined) continue;
|
|
149
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
150
|
+
issues.push(
|
|
151
|
+
`project.json: shell.audio.volumes.${channel} 必须为 0..1 的有限数字,收到 ${JSON.stringify(raw)}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
128
159
|
}
|
|
129
160
|
}
|
|
130
161
|
if (issues.length > 0) throw new ProjectAssemblyError(issues);
|
|
@@ -135,7 +166,7 @@ export function assembleProject(
|
|
|
135
166
|
...((manifest.defines as Record<string, unknown> | undefined) ?? {}),
|
|
136
167
|
};
|
|
137
168
|
|
|
138
|
-
for (const path of [...files.keys()].sort(
|
|
169
|
+
for (const path of [...files.keys()].sort(compareOrdinal)) {
|
|
139
170
|
// **只处理 `Stories/` 下的文件**。
|
|
140
171
|
//组装器**假定**调用方只喂故事文件,若喂了全量资源根就会组装失败:
|
|
141
172
|
// `Lang/en-US/main.json`(译文表)等全被当故事解析
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { compareOrdinal } from "../../shared/index.ts";
|
|
2
|
+
import { STORIES_DIR, isSafeFileNameSegment } from "./naming.ts";
|
|
2
3
|
import {
|
|
3
4
|
defaultEngineTexts,
|
|
4
5
|
type EngineTexts,
|
|
@@ -84,7 +85,7 @@ export function detectWriteNormalization(
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
return {
|
|
87
|
-
toConvert: toConvert.sort(
|
|
88
|
-
toRemove: toRemove.sort(
|
|
88
|
+
toConvert: toConvert.sort(compareOrdinal),
|
|
89
|
+
toRemove: toRemove.sort(compareOrdinal),
|
|
89
90
|
};
|
|
90
91
|
}
|
package/src/data/project/diff.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isPlainObject } from "../../shared/index.ts";
|
|
2
|
-
import {
|
|
1
|
+
import { compareOrdinal, isPlainObject } from "../../shared/index.ts";
|
|
2
|
+
import { STORIES_DIR } from "./naming.ts";
|
|
3
3
|
import type { ProjectFileDiff } from "../../contracts/index.ts";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -56,6 +56,6 @@ export function diffProjectFiles(
|
|
|
56
56
|
}
|
|
57
57
|
const deletes = [...previous.keys()]
|
|
58
58
|
.filter((path) => path.startsWith(`${STORIES_DIR}/`) && !wanted.has(path))
|
|
59
|
-
.sort(
|
|
59
|
+
.sort(compareOrdinal);
|
|
60
60
|
return { changes, deletes };
|
|
61
61
|
}
|
|
@@ -13,7 +13,12 @@ export type {
|
|
|
13
13
|
WriteNormalizationFinding,
|
|
14
14
|
} from "../../contracts/index.ts";
|
|
15
15
|
export { ProjectAssemblyError, ProjectSerializationError } from "./error.ts";
|
|
16
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
isSafeFileNameSegment,
|
|
18
|
+
MANIFEST_FILE,
|
|
19
|
+
RESOURCES_DIR,
|
|
20
|
+
STORIES_DIR,
|
|
21
|
+
} from "./naming.ts";
|
|
17
22
|
export { assembleProject, synthesizeDegradedManifest } from "./assemble.ts";
|
|
18
23
|
export { serializeColumnDocument, serializeProject } from "./serialize.ts";
|
|
19
24
|
export { diffProjectFiles } from "./diff.ts";
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 工程文件的命名与路径判据:清单名、故事目录名、文件名安全段。
|
|
3
3
|
* 组装、写回、差量与冲突检测都依赖同一套判据,这里只声明一份。
|
|
4
|
+
*
|
|
5
|
+
* 路径排序不在这里:码元序比较器全仓只有 `shared/ordering.ts` 的 `compareOrdinal` 一处。
|
|
4
6
|
*/
|
|
5
|
-
/** 确定性文件顺序(路径码元序)——defines 覆盖与列序不随 Map 构造顺序漂移 */
|
|
6
|
-
export function byPath(a: string, b: string): number {
|
|
7
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
8
|
-
}
|
|
9
7
|
|
|
10
8
|
/** 清单文件名(清单必须在资源根内——dev/prod 同机制) */
|
|
11
9
|
export const MANIFEST_FILE = "project.json";
|
|
@@ -13,6 +11,14 @@ export const MANIFEST_FILE = "project.json";
|
|
|
13
11
|
/** 故事目录名(Rust `STORIES_DIR` 同名) */
|
|
14
12
|
export const STORIES_DIR = "Stories";
|
|
15
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 资源根目录名(相对工程根):清单、`Stories/**` 与素材都住在它下面。
|
|
16
|
+
*
|
|
17
|
+
* 两侧共用同一份声明——引擎侧的资源适配器按它定位资源根(所选目录不含清单时
|
|
18
|
+
* 下探这一层),构建侧按它落清单与故事产物。改名只改这里,两边不会错位。
|
|
19
|
+
*/
|
|
20
|
+
export const RESOURCES_DIR = "Resources";
|
|
21
|
+
|
|
16
22
|
/**
|
|
17
23
|
* 点文件/点目录判据(`.gitkeep` / `.gitignore` …)—— **任何一段**以 `.` 开头即是。
|
|
18
24
|
*
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
import { isPlainObject } from "../../shared/index.ts";
|
|
1
|
+
import { compareOrdinal, isPlainObject } from "../../shared/index.ts";
|
|
2
2
|
import { ProjectSerializationError } from "./error.ts";
|
|
3
|
-
import {
|
|
4
|
-
byPath,
|
|
5
|
-
MANIFEST_FILE,
|
|
6
|
-
STORIES_DIR,
|
|
7
|
-
isSafeFileNameSegment,
|
|
8
|
-
} from "./naming.ts";
|
|
3
|
+
import { MANIFEST_FILE, STORIES_DIR, isSafeFileNameSegment } from "./naming.ts";
|
|
9
4
|
import { columnShapeDefect } from "../format/column.ts";
|
|
10
5
|
import {
|
|
11
6
|
CAPABILITY_VERSION,
|
|
7
|
+
normalizeSceneType,
|
|
8
|
+
normalizeUiNames,
|
|
9
|
+
type SceneType,
|
|
12
10
|
type SerializedProject,
|
|
13
11
|
type Story,
|
|
14
12
|
type StoryColumn,
|
|
@@ -23,20 +21,48 @@ function stableJson(value: unknown): string {
|
|
|
23
21
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* 落盘的 `type` 值(**唯一写出点**):`undefined` = 不写字段(缺省 game 保持文件干净)。
|
|
26
|
+
*
|
|
27
|
+
* 写出恒为契约值 `game` / `ui`:内存故事树里若带旧拼写(编辑期脚本、外部构造),
|
|
28
|
+
* 也不会把别名写回磁盘 —— 否则存量工程每存一次都可能换一次拼写。
|
|
29
|
+
*/
|
|
30
|
+
function writeBackSceneType(column: StoryColumn): SceneType | undefined {
|
|
31
|
+
if (column.type === undefined) return undefined;
|
|
32
|
+
const sceneType = normalizeSceneType(column.type);
|
|
33
|
+
return sceneType === "game" ? undefined : sceneType;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 落盘的列级 `uis`(**唯一写出点**):`undefined` / 空数组 = 不写字段,否则写归一后的数组。
|
|
38
|
+
*
|
|
39
|
+
* 归一(去重 + 码点序)在写出侧再做一次:内存故事树可能由编辑期脚本或外部工具构造,
|
|
40
|
+
* 不保证已经排过序。漏了这一步,同一个工程在不同保存路径下会产出不同字节。
|
|
41
|
+
*/
|
|
42
|
+
function writeBackUis(column: StoryColumn): readonly string[] | undefined {
|
|
43
|
+
if (column.uis === undefined) return undefined;
|
|
44
|
+
const names = normalizeUiNames(column.uis);
|
|
45
|
+
return names.length === 0 ? undefined : names;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 单列原子文件文本:字段序固定(formatVersion, id, kind, type?, font?, uis?, commands|elements, entry?) */
|
|
27
49
|
function columnFileText(column: StoryColumn): string {
|
|
28
50
|
const ordered: Record<string, unknown> = {
|
|
29
51
|
formatVersion: 1,
|
|
30
52
|
id: column.id,
|
|
31
53
|
kind: column.kind,
|
|
32
54
|
};
|
|
33
|
-
// `type`(运行语义)**必须写回**:漏了会让
|
|
34
|
-
// 变成 game(保存 =
|
|
55
|
+
// `type`(运行语义)**必须写回**:漏了会让 ui 场景在下次打开时
|
|
56
|
+
// 变成 game(保存 = 悄悄改语义,且**不可逆**——作者下次打开发现界面层能回溯了)。
|
|
35
57
|
// 缺省 game 不写(保持文件干净),非缺省才写。
|
|
36
|
-
|
|
37
|
-
|
|
58
|
+
const sceneType = writeBackSceneType(column);
|
|
59
|
+
if (sceneType !== undefined) ordered.type = sceneType;
|
|
38
60
|
// 列级字体(纯展示元数据)必须写回:漏了会让保存悄悄抹掉列排版(不可逆)。
|
|
39
61
|
if (column.font !== undefined) ordered.font = column.font;
|
|
62
|
+
// 列级 UI 声明同理:漏了会让保存把「本列引用哪些挂载 UI」清空,
|
|
63
|
+
// 宿主下次打开就少预加载(症状是界面闪一下才出现,很难归因到一次保存)。
|
|
64
|
+
const uis = writeBackUis(column);
|
|
65
|
+
if (uis !== undefined) ordered.uis = uis;
|
|
40
66
|
if (column.kind === "flow") {
|
|
41
67
|
ordered.commands = column.commands ?? [];
|
|
42
68
|
} else {
|
|
@@ -91,7 +117,9 @@ function manifestText(story: Story, manifest: Record<string, unknown>): string {
|
|
|
91
117
|
|
|
92
118
|
/** 按路径码元序重排文件表,让写回/比对顺序不随 Map 插入顺序漂移(结果可复现) */
|
|
93
119
|
function sortByCodeUnit(files: Map<string, string>): Map<string, string> {
|
|
94
|
-
return new Map(
|
|
120
|
+
return new Map(
|
|
121
|
+
[...files.entries()].sort((a, b) => compareOrdinal(a[0], b[0])),
|
|
122
|
+
);
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
/**
|
|
@@ -150,7 +178,7 @@ export function serializeProject(
|
|
|
150
178
|
}
|
|
151
179
|
if (shape.reason === "bad-type") {
|
|
152
180
|
issues.push(
|
|
153
|
-
`列 ${id} 的 type 必须为 "game" / "
|
|
181
|
+
`列 ${id} 的 type 必须为 "game" / "ui",收到 ${JSON.stringify(shape.received)}`,
|
|
154
182
|
);
|
|
155
183
|
continue;
|
|
156
184
|
}
|
|
@@ -213,9 +241,14 @@ function columnGroupFileText(
|
|
|
213
241
|
id: column.id,
|
|
214
242
|
kind: column.kind,
|
|
215
243
|
};
|
|
216
|
-
|
|
217
|
-
|
|
244
|
+
const sceneType = writeBackSceneType(column);
|
|
245
|
+
if (sceneType !== undefined) {
|
|
246
|
+
ordered.type = sceneType;
|
|
218
247
|
}
|
|
248
|
+
// 多列形态与单列形态的字段集必须一致:同一列换一种文件形态保存,
|
|
249
|
+
// 字段不该跟着丢(`uis` 丢了宿主就少预加载,且不可逆)。
|
|
250
|
+
const uis = writeBackUis(column);
|
|
251
|
+
if (uis !== undefined) ordered.uis = uis;
|
|
219
252
|
if (column.kind === "flow") ordered.commands = column.commands ?? [];
|
|
220
253
|
else {
|
|
221
254
|
ordered.elements = column.elements ?? [];
|
package/src/data/text/entry.ts
CHANGED
|
@@ -18,10 +18,12 @@ import type {
|
|
|
18
18
|
Story,
|
|
19
19
|
StoryColumn,
|
|
20
20
|
StoryCommand,
|
|
21
|
+
SceneType,
|
|
21
22
|
ElementNode,
|
|
22
23
|
CustomOpProjections,
|
|
23
24
|
TextProjection,
|
|
24
25
|
} from "../../contracts/index.ts";
|
|
26
|
+
import { sceneTypeOf } from "../../shared/index.ts";
|
|
25
27
|
|
|
26
28
|
/** 文本 → Story(缩进块、注释、全 op 文法;失败整次拒绝并带行列定位)。
|
|
27
29
|
* `projections`(可选)= 扩展自定义 op 的行投影表;缺省 = 现行为逐字节不变。 */
|
|
@@ -77,20 +79,18 @@ export function parseTextStory(
|
|
|
77
79
|
if (name === "" || columns.some((c) => c.id === name)) {
|
|
78
80
|
state.issues.push(`${at}: scene 名为空或重复:${name}`);
|
|
79
81
|
}
|
|
80
|
-
// `type=
|
|
81
|
-
//
|
|
82
|
-
// ⇒
|
|
83
|
-
|
|
82
|
+
// `type=game|ui`:**映射到列的运行语义**。
|
|
83
|
+
// 若「接受但忽略」,作者的界面层场景**会被当成 game**
|
|
84
|
+
// ⇒ 保存后界面层会变成可回溯剧情(且不可逆)。
|
|
85
|
+
// 无从识别的 `type=xxx` 仍走下面的「忽略无法识别的参数」告警,不静默改语义。
|
|
86
|
+
let sceneType: SceneType | undefined;
|
|
84
87
|
const rest: string[] = [];
|
|
85
88
|
for (let t = 2; t < sceneTokens.length; t += 1) {
|
|
86
89
|
const token = sceneTokens[t]!;
|
|
87
90
|
const eq = token.indexOf("=");
|
|
88
91
|
const key = eq > 0 ? token.slice(0, eq) : "";
|
|
89
92
|
const value = eq > 0 ? unquote(token.slice(eq + 1)) : "";
|
|
90
|
-
if (
|
|
91
|
-
key === "type" &&
|
|
92
|
-
(value === "game" || value === "menu" || value === "ui")
|
|
93
|
-
) {
|
|
93
|
+
if (key === "type" && (value === "game" || value === "ui")) {
|
|
94
94
|
sceneType = value;
|
|
95
95
|
continue;
|
|
96
96
|
}
|
|
@@ -194,7 +194,15 @@ export function projectText(
|
|
|
194
194
|
for (const column of story.columns) {
|
|
195
195
|
if (column.kind === "scene") {
|
|
196
196
|
// scene 列:元素行在前、entry 命令在后(与装载语义一致——先声明空间层再执行)
|
|
197
|
-
|
|
197
|
+
//
|
|
198
|
+
// `type` 只写非缺省值(`game` 是常态,写进去会让文本噪声变大);写出的恒是
|
|
199
|
+
// 契约值 `ui`——旧拼写 `menu` 解析时已归一,投影不还原别名(项目里只允许一种写法)。
|
|
200
|
+
const sceneType = sceneTypeOf(column);
|
|
201
|
+
out.push(
|
|
202
|
+
sceneType === "game"
|
|
203
|
+
? `scene ${column.id}`
|
|
204
|
+
: `scene ${column.id} type=${sceneType}`,
|
|
205
|
+
);
|
|
198
206
|
for (const node of column.elements ?? []) {
|
|
199
207
|
tolerant(`元素 ${String(node.type ?? "")}`, issues, () =>
|
|
200
208
|
generateElement(node, " ", out),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** dialog 族的文本投影。 */
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
quoteForText,
|
|
4
|
+
instanceZText,
|
|
5
|
+
generateInputOptions,
|
|
6
|
+
} from "../../literals.ts";
|
|
3
7
|
import type { StoryCommand } from "../../../../contracts/index.ts";
|
|
4
8
|
import type { GenerateContext } from "../context.ts";
|
|
5
9
|
|
|
@@ -51,11 +55,19 @@ export function generateDialogCommand(
|
|
|
51
55
|
case "pause":
|
|
52
56
|
out.push(`${pad}pause ${cmd.seconds}`);
|
|
53
57
|
return;
|
|
54
|
-
case "input":
|
|
58
|
+
case "input": {
|
|
59
|
+
// 候选数组内部含空格与引号,必须整体投影成一行内联字面量(解析侧同法整体摘取)
|
|
60
|
+
const candidates = cmd.options as
|
|
61
|
+
Array<{ text: string; value: string }> | undefined;
|
|
62
|
+
const optionsText =
|
|
63
|
+
Array.isArray(candidates) && candidates.length > 0
|
|
64
|
+
? ` options=${generateInputOptions(candidates)}`
|
|
65
|
+
: "";
|
|
55
66
|
out.push(
|
|
56
|
-
`${pad}input ${quoteForText(cmd.prompt as string)} store=${quoteForText(cmd.store as string)}${instanceZText(cmd)}`,
|
|
67
|
+
`${pad}input ${quoteForText(cmd.prompt as string)} store=${quoteForText(cmd.store as string)}${optionsText}${instanceZText(cmd)}`,
|
|
57
68
|
);
|
|
58
69
|
return;
|
|
70
|
+
}
|
|
59
71
|
case "nvl": {
|
|
60
72
|
const mode = (cmd.mode as string | undefined) ?? "enter";
|
|
61
73
|
out.push(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 命令文本生成族出口:按命令族分片的生成器。
|
|
3
3
|
*
|
|
4
|
-
* 轴 = 命令族(块体 / 集合 / 对话 / 流程 / 媒体 / 表演 / 存档 / 变量)。缩进与投影器由
|
|
4
|
+
* 轴 = 命令族(块体 / 集合 / 对话 / 流程 / 媒体 / 表演 / 存档 / UI / 变量)。缩进与投影器由
|
|
5
5
|
* 调用方注入,族文件只认自己那族的字段。
|
|
6
6
|
*/
|
|
7
7
|
export { generateBlocksCommand } from "./blocks.ts";
|
|
@@ -11,4 +11,5 @@ export { generateFlowCommand } from "./flow.ts";
|
|
|
11
11
|
export { generateMediaCommand } from "./media.ts";
|
|
12
12
|
export { generatePresentCommand } from "./present.ts";
|
|
13
13
|
export { generateSaveCommand } from "./save.ts";
|
|
14
|
+
export { generateUiCommand } from "./ui.ts";
|
|
14
15
|
export { generateVariablesCommand } from "./variables.ts";
|