@lingfan-engine/core 0.2.0 → 0.3.1

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.
Files changed (85) hide show
  1. package/package.json +1 -1
  2. package/src/contracts/guard.ts +9 -0
  3. package/src/contracts/index.ts +1 -0
  4. package/src/contracts/media/entities.ts +85 -3
  5. package/src/contracts/media/ports.ts +48 -0
  6. package/src/contracts/param-direction.ts +103 -0
  7. package/src/contracts/runtime/column-summary.ts +24 -0
  8. package/src/contracts/runtime/controls.ts +49 -0
  9. package/src/contracts/runtime/entities.ts +21 -0
  10. package/src/contracts/runtime/index.ts +2 -0
  11. package/src/contracts/runtime/reader.ts +28 -1
  12. package/src/contracts/runtime/state-keys.ts +62 -2
  13. package/src/contracts/runtime/timer.ts +8 -1
  14. package/src/contracts/save.ts +18 -1
  15. package/src/contracts/shell.ts +26 -0
  16. package/src/contracts/story/entities.ts +23 -8
  17. package/src/data/format/column.ts +33 -4
  18. package/src/data/index.ts +1 -0
  19. package/src/data/project/assemble.ts +37 -6
  20. package/src/data/project/conflict.ts +4 -3
  21. package/src/data/project/diff.ts +3 -3
  22. package/src/data/project/index.ts +6 -1
  23. package/src/data/project/naming.ts +11 -5
  24. package/src/data/project/serialize.ts +49 -16
  25. package/src/data/text/entry.ts +17 -9
  26. package/src/data/text/generate/command/index.ts +2 -1
  27. package/src/data/text/generate/command/media.ts +33 -2
  28. package/src/data/text/generate/command/ui.ts +29 -0
  29. package/src/data/text/generate/command.ts +10 -0
  30. package/src/data/text/parse/statement/flow.ts +1 -1
  31. package/src/data/text/parse/statement/index.ts +2 -1
  32. package/src/data/text/parse/statement/media.ts +91 -2
  33. package/src/data/text/parse/statement/ui.ts +47 -0
  34. package/src/data/text/parse/statement.ts +9 -0
  35. package/src/index.ts +8 -1
  36. package/src/runtime/dispatch.ts +24 -0
  37. package/src/runtime/engine/columns.ts +31 -0
  38. package/src/runtime/engine/forwarders.ts +7 -4
  39. package/src/runtime/engine/game-state.ts +29 -0
  40. package/src/runtime/engine/index.ts +2 -0
  41. package/src/runtime/engine/state.ts +13 -1
  42. package/src/runtime/engine.ts +46 -21
  43. package/src/runtime/expr/builtins.ts +1 -1
  44. package/src/runtime/expr/evaluate.ts +1 -1
  45. package/src/runtime/expr/index.ts +1 -1
  46. package/src/runtime/expr/interpolate.ts +1 -1
  47. package/src/runtime/expr/parser/cursor.ts +1 -1
  48. package/src/runtime/history/index.ts +1 -0
  49. package/src/runtime/history/rollback.ts +23 -8
  50. package/src/runtime/index.ts +9 -3
  51. package/src/runtime/internal/context.ts +25 -2
  52. package/src/runtime/internal/helpers.ts +18 -0
  53. package/src/runtime/internal/index.ts +9 -1
  54. package/src/runtime/internal/session.ts +11 -0
  55. package/src/runtime/internal/waiting.ts +51 -3
  56. package/src/runtime/opRegistry.ts +7 -0
  57. package/src/runtime/ops/dialog.ts +2 -11
  58. package/src/runtime/ops/flow.ts +1 -1
  59. package/src/runtime/ops/index.ts +11 -1
  60. package/src/runtime/ops/media/audio.ts +230 -0
  61. package/src/runtime/ops/media/index.ts +18 -0
  62. package/src/runtime/ops/media/instances.ts +312 -0
  63. package/src/runtime/ops/media/notify.ts +32 -0
  64. package/src/runtime/ops/media/shared.ts +133 -0
  65. package/src/runtime/ops/media/video.ts +118 -0
  66. package/src/runtime/ops/ui.ts +95 -0
  67. package/src/runtime/paramDirection.ts +35 -0
  68. package/src/runtime/save/export.ts +52 -6
  69. package/src/runtime/save/import.ts +21 -6
  70. package/src/runtime/scope.ts +17 -0
  71. package/src/runtime/session/index.ts +21 -0
  72. package/src/runtime/session/pause.ts +114 -0
  73. package/src/runtime/{waitingOps.ts → session/waitingOps.ts} +1 -1
  74. package/src/runtime/shell.ts +68 -0
  75. package/src/runtime/state/index.ts +2 -1
  76. package/src/runtime/state/reader.ts +32 -1
  77. package/src/runtime/state/writer.ts +1 -1
  78. package/src/shared/audio.ts +106 -0
  79. package/src/shared/guards.ts +38 -4
  80. package/src/shared/index.ts +3 -0
  81. package/src/shared/mounts.ts +42 -0
  82. package/src/shared/ui-name.ts +41 -0
  83. package/src/runtime/ops/media.ts +0 -274
  84. /package/src/runtime/{resolver.ts → expr/resolver.ts} +0 -0
  85. /package/src/runtime/{stateContract.ts → state/stateContract.ts} +0 -0
@@ -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/menu/ui)= **运行语义**:进不进历史与存档、菜单是否自动推进
28
+ * - `type`(game/ui)= **运行语义**:进不进历史与存档
29
29
  *
30
- * 三态语义:
30
+ * 两态语义:
31
31
  * - `game`:实际游戏场景 —— **存档、进历史、建立检查点**
32
- * - `menu`:菜单 / 标题 / 设置 —— 不存档、不进历史、不建检查点(**可覆盖**)
33
- * - `ui`:覆盖层 / 弹窗 —— 同上,且**不改变历史游标**
32
+ * - `ui`:界面层(菜单 / 标题 / 设置 / 覆盖层 / 弹窗)—— 不存档、不进历史、不建检查点
33
+ *
34
+ * **两者在运行期没有行为差异**:唯一的判定只有「进不进历史与存档」。所以不拆成
35
+ * 「菜单」「界面」两个类型——那点区别不存在于引擎里,多一个取值只会让每个消费者
36
+ * 都得再判一次。界面内部怎么分类(标题页 / 设置页 / 弹窗)归页面层自己表达。
34
37
  *
35
38
  * **缺省 = `game`** ⇒ 既有工程零改动。
36
39
  */
37
- export type SceneType = "game" | "menu" | "ui";
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: "menu"`(纯流程的菜单)是合法组合。
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.9.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/menu/ui)**缺省合法 = 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" / "menu" / "ui",收到 ${JSON.stringify(shape.received)}`,
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
- const type = raw.type as SceneType | undefined;
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,
package/src/data/index.ts CHANGED
@@ -16,6 +16,7 @@ export {
16
16
  isSafeFileNameSegment,
17
17
  MANIFEST_FILE,
18
18
  ProjectSerializationError,
19
+ RESOURCES_DIR,
19
20
  serializeColumnDocument,
20
21
  serializeProject,
21
22
  STORIES_DIR,
@@ -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 { byPath, isDotPath, MANIFEST_FILE, STORIES_DIR } from "./naming.ts";
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(byPath)) {
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 orientation = (manifest.shell as { orientation?: unknown })
122
- .orientation;
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(byPath)) {
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 { byPath, STORIES_DIR, isSafeFileNameSegment } from "./naming.ts";
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(byPath),
88
- toRemove: toRemove.sort(byPath),
88
+ toConvert: toConvert.sort(compareOrdinal),
89
+ toRemove: toRemove.sort(compareOrdinal),
89
90
  };
90
91
  }
@@ -1,5 +1,5 @@
1
- import { isPlainObject } from "../../shared/index.ts";
2
- import { byPath, STORIES_DIR } from "./naming.ts";
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(byPath);
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 { isSafeFileNameSegment, MANIFEST_FILE, STORIES_DIR } from "./naming.ts";
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
- /** 单列原子文件文本:字段序固定(formatVersion, id, kind, type?, font?, commands|elements, entry?) */
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`(运行语义)**必须写回**:漏了会让 menu/ui 场景在下次打开时
34
- // 变成 game(保存 = 悄悄改语义,且**不可逆**——作者下次打开发现菜单能回溯了)。
55
+ // `type`(运行语义)**必须写回**:漏了会让 ui 场景在下次打开时
56
+ // 变成 game(保存 = 悄悄改语义,且**不可逆**——作者下次打开发现界面层能回溯了)。
35
57
  // 缺省 game 不写(保持文件干净),非缺省才写。
36
- if (column.type !== undefined && column.type !== "game")
37
- ordered.type = column.type;
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([...files.entries()].sort((a, b) => byPath(a[0], b[0])));
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" / "menu" / "ui",收到 ${JSON.stringify(shape.received)}`,
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
- if (column.type !== undefined && column.type !== "game") {
217
- ordered.type = column.type;
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 ?? [];
@@ -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=menu|ui|game`:**映射到列的运行语义**。
81
- // 若「接受但忽略」,作者的 menu 场景**会被当成 game**
82
- // ⇒ 保存后菜单会变成可回溯剧情(且不可逆)。
83
- let sceneType: "game" | "menu" | "ui" | undefined;
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
- out.push(`scene ${column.id}`);
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,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";
@@ -4,7 +4,9 @@ import type { StoryCommand } from "../../../../contracts/index.ts";
4
4
  import type { GenerateContext } from "../context.ts";
5
5
 
6
6
  /**
7
- * 投影本族的 op 行(`bgm` `se` `ambient` `voice` `stop_bgm` `stop_ambient` `stop_voice` `video` `cutscene` `seek_video` `pause_video` `resume_video` `stop_video` `video_skipable`)。
7
+ * 投影本族的 op 行(`bgm` `se` `ambient` `voice` `stop_bgm` `stop_ambient` `stop_voice` `stop_se`
8
+ * `audio_play` `audio_stop` `audio_volume` `video` `cutscene` `seek_video` `pause_video`
9
+ * `resume_video` `stop_video` `video_skipable`)。
8
10
  *
9
11
  * 入参是分发骨架建好的 `GenerateContext`:`pad` 是本行缩进前缀(块体逐层加深),
10
12
  * `out` 是输出缓冲,投影出的行直接推入,不留空行。
@@ -33,12 +35,32 @@ export function generateMediaCommand(
33
35
  }
34
36
  case "stop_bgm":
35
37
  case "stop_ambient":
36
- case "stop_voice": {
38
+ case "stop_voice":
39
+ case "stop_se": {
37
40
  let line = `${pad}${cmd.op}`;
38
41
  if (cmd.fade !== undefined) line += ` fade=${cmd.fade}`;
39
42
  out.push(line);
40
43
  return;
41
44
  }
45
+ case "audio_play": {
46
+ let line = `${pad}audio_play ${cmd.inst as string} ${cmd.channel as string} ${quoteForText(cmd.resource as string)}`;
47
+ if (cmd.volume !== undefined) line += ` volume=${cmd.volume}`;
48
+ if (cmd.loop !== undefined) line += ` loop=${cmd.loop}`;
49
+ if (cmd.fade !== undefined) line += ` fade=${cmd.fade}`;
50
+ if (cmd.restart !== undefined) line += ` restart=${cmd.restart}`;
51
+ out.push(line);
52
+ return;
53
+ }
54
+ case "audio_stop": {
55
+ let line = `${pad}audio_stop`;
56
+ if (cmd.inst !== undefined) line += ` ${cmd.inst as string}`;
57
+ if (cmd.fade !== undefined) line += ` fade=${cmd.fade}`;
58
+ out.push(line);
59
+ return;
60
+ }
61
+ case "audio_volume":
62
+ out.push(`${pad}audio_volume ${cmd.inst as string} volume=${cmd.volume}`);
63
+ return;
42
64
  case "video":
43
65
  case "cutscene": {
44
66
  let line = `${pad}${cmd.op} ${quoteForText(cmd.resource as string)}`;
@@ -61,6 +83,15 @@ export function generateMediaCommand(
61
83
  case "stop_video":
62
84
  out.push(`${pad}stop_video`);
63
85
  return;
86
+ case "audio_pause":
87
+ case "audio_resume":
88
+ // channel 省略即「全部常驻通道」,不能补默认值——写出来会变成只暂停某一个
89
+ out.push(
90
+ cmd.channel === undefined
91
+ ? `${pad}${cmd.op}`
92
+ : `${pad}${cmd.op} ${cmd.channel as string}`,
93
+ );
94
+ return;
64
95
  case "video_skipable":
65
96
  out.push(
66
97
  `${pad}video_skipable ${cmd.value === false ? "false" : "true"}`,
@@ -0,0 +1,29 @@
1
+ /** ui 族的文本投影。 */
2
+ import { generateDictLiteral } from "../../literals.ts";
3
+ import type { StoryCommand } from "../../../../contracts/index.ts";
4
+ import type { GenerateContext } from "../context.ts";
5
+
6
+ /**
7
+ * 投影本族的 op 行(`mount`)。
8
+ *
9
+ * 入参是分发骨架建好的 `GenerateContext`:`pad` 是本行缩进前缀(块体逐层加深),
10
+ * `out` 是输出缓冲,投影出的行直接推入,不留空行。
11
+ * 无返回值,结果通过 `out` 交付。
12
+ * 失败表现:字段无法投影(`props` 不是对象等)时抛 `TextFormatError`,不是静默跳过——
13
+ * 由上层按命令粒度捕获后降级为 issue,使整棵树的其余部分照常输出。
14
+ *
15
+ * 形态:`mount <UI 名> <true|false> [props={…}]`。UI 名是短标识(不含引号也不含空白),
16
+ * 显隐写成字面 `true` / `false`——不写「切换」,文本形态与状态语义一样确定。
17
+ */
18
+ export function generateUiCommand(cmd: StoryCommand, c: GenerateContext): void {
19
+ const { pad, out } = c;
20
+ switch (cmd.op) {
21
+ case "mount": {
22
+ let line = `${pad}mount ${cmd.name as string} ${cmd.visible === true ? "true" : "false"}`;
23
+ if (cmd.props !== undefined)
24
+ line += ` props=${generateDictLiteral(cmd.props as Record<string, unknown>)}`;
25
+ out.push(line);
26
+ return;
27
+ }
28
+ }
29
+ }
@@ -13,6 +13,7 @@ import {
13
13
  generateMediaCommand,
14
14
  generatePresentCommand,
15
15
  generateSaveCommand,
16
+ generateUiCommand,
16
17
  generateVariablesCommand,
17
18
  } from "./command/index.ts";
18
19
  import { tryProjectToText } from "./projection.ts";
@@ -88,6 +89,12 @@ export function generateCommand(
88
89
  case "stop_bgm":
89
90
  case "stop_ambient":
90
91
  case "stop_voice":
92
+ case "stop_se":
93
+ case "audio_play":
94
+ case "audio_stop":
95
+ case "audio_volume":
96
+ case "audio_pause":
97
+ case "audio_resume":
91
98
  case "video":
92
99
  case "cutscene":
93
100
  case "seek_video":
@@ -120,6 +127,9 @@ export function generateCommand(
120
127
  case "text_typewriter":
121
128
  generatePresentCommand(cmd, ctx);
122
129
  return;
130
+ case "mount":
131
+ generateUiCommand(cmd, ctx);
132
+ return;
123
133
  default: {
124
134
  // 扩展投影:声明了 toText 的自定义 op 交给投影器;否则 fail-closed(不静默)
125
135
  const toText = projections?.get(cmd.op)?.toText;