@lingfan-engine/core 0.2.0 → 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.
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
@@ -113,7 +113,7 @@ export function parseFlowStatement(c: StatementContext): StoryCommand | null {
113
113
  // ——若一律落到「暂不支持的语句」,整个工程就打不开。
114
114
  //
115
115
  // 语义对齐:`scene "x"`(跳转)≡ `navigate "x"`(坐标切换命令)。
116
- // 区别于**列声明**的 `scene "名" type=menu`(那个在顶层解析,见 `parseTextStory`)。
116
+ // 区别于**列声明**的 `scene "名" type=ui`(那个在顶层解析,见 `parseTextStory`)。
117
117
  // `tokens[0]` 是 op 本身,目标名在 `tokens[1]`(`splitTokens` 的口径)
118
118
  const target = quoted(0);
119
119
  if (target === "") return fail("scene 需要目标场景名");
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 语句解析族出口:按命令族分片的语句解析器。
3
3
  *
4
- * 轴 = 命令族(集合 / 对话 / 流程 / 媒体 / 表演 / 存档 / 变量),每片只认自己那族的关键字。
4
+ * 轴 = 命令族(集合 / 对话 / 流程 / 媒体 / 表演 / 存档 / UI / 变量),每片只认自己那族的关键字。
5
5
  */
6
6
  export { parseCollectionsStatement } from "./collections.ts";
7
7
  export { parseDialogStatement } from "./dialog.ts";
@@ -9,4 +9,5 @@ export { parseFlowStatement } from "./flow.ts";
9
9
  export { parseMediaStatement } from "./media.ts";
10
10
  export { parsePresentStatement } from "./present.ts";
11
11
  export { parseSaveStatement } from "./save.ts";
12
+ export { parseUiStatement } from "./ui.ts";
12
13
  export { parseVariablesStatement } from "./variables.ts";
@@ -1,10 +1,13 @@
1
1
  /** media 族的读向解析。 */
2
2
  import { unquote } from "../../lexer.ts";
3
+ import { isAudioChannel } from "../../../../shared/index.ts";
3
4
  import type { StoryCommand } from "../../../../contracts/index.ts";
4
5
  import type { StatementContext } from "../state.ts";
5
6
 
6
7
  /**
7
- * 解析本族的 op 行(`bgm` `se` `ambient` `voice` `stop_bgm` `stop_ambient` `stop_voice` `video` `cutscene` `seek_video` `pause_video` `resume_video` `stop_video` `video_skipable`)。
8
+ * 解析本族的 op 行(`bgm` `se` `ambient` `voice` `stop_bgm` `stop_ambient` `stop_voice` `stop_se`
9
+ * `audio_play` `audio_stop` `audio_volume` `audio_pause` `audio_resume` `video` `cutscene`
10
+ * `seek_video` `pause_video` `resume_video` `stop_video` `video_skipable`)。
8
11
  *
9
12
  * 入参是分发骨架建好的 `StatementContext`(已切好的词表、定位前缀、issue 出口与警告通道)。
10
13
  * 只处理本族的 op,不匹配时落到末尾 `return null`,由骨架决定后续。
@@ -51,7 +54,8 @@ export function parseMediaStatement(c: StatementContext): StoryCommand | null {
51
54
  }
52
55
  case "stop_bgm":
53
56
  case "stop_ambient":
54
- case "stop_voice": {
57
+ case "stop_voice":
58
+ case "stop_se": {
55
59
  const cmd: StoryCommand = { op };
56
60
  for (const token of tokens) {
57
61
  const eq = token.indexOf("=");
@@ -65,6 +69,79 @@ export function parseMediaStatement(c: StatementContext): StoryCommand | null {
65
69
  }
66
70
  return cmd;
67
71
  }
72
+ // 音频实例族:位置参数是 id(与 resource 的引号形态区分开,作者一眼能看出哪个是名字)
73
+ case "audio_play": {
74
+ const inst = tokens[0];
75
+ const channel = tokens[1];
76
+ const resource = tokens.find((t) => t.startsWith('"'));
77
+ if (inst === undefined) return fail("audio_play 需要实例 id");
78
+ if (channel === undefined) return fail("audio_play 需要 channel");
79
+ if (resource === undefined) return fail("audio_play 需要 resource");
80
+ const cmd: StoryCommand = {
81
+ op: "audio_play",
82
+ inst,
83
+ channel,
84
+ resource: unquote(resource),
85
+ };
86
+ for (const token of tokens) {
87
+ if (token === inst || token === channel || token.startsWith('"'))
88
+ continue;
89
+ const eq = token.indexOf("=");
90
+ const key = eq < 0 ? token : token.slice(0, eq);
91
+ const raw = eq < 0 ? "" : token.slice(eq + 1);
92
+ if (key === "volume" || key === "fade") {
93
+ const num = Number(raw);
94
+ if (eq < 0 || raw === "" || Number.isNaN(num))
95
+ return fail(`audio_play 的 ${key} 需要数字`);
96
+ cmd[key] = num;
97
+ } else if (key === "loop" || key === "restart") {
98
+ if (raw !== "true" && raw !== "false")
99
+ return fail(`audio_play 的 ${key} 需要 true|false`);
100
+ cmd[key] = raw === "true";
101
+ } else {
102
+ return fail(`audio_play 未知参数:${token}`);
103
+ }
104
+ }
105
+ return cmd;
106
+ }
107
+ case "audio_stop": {
108
+ const cmd: StoryCommand = { op: "audio_stop" };
109
+ for (const token of tokens) {
110
+ const eq = token.indexOf("=");
111
+ if (eq < 0) {
112
+ // 唯一的无等号位置参数是目标实例;重复给就是写错
113
+ if (cmd.inst !== undefined)
114
+ return fail(`audio_stop 只有一个实例位置参数:${token}`);
115
+ cmd.inst = token;
116
+ continue;
117
+ }
118
+ const key = token.slice(0, eq);
119
+ const raw = token.slice(eq + 1);
120
+ if (key !== "fade") return fail(`audio_stop 未知参数:${token}`);
121
+ const num = Number(raw);
122
+ if (raw === "" || Number.isNaN(num))
123
+ return fail("audio_stop 的 fade 需要数字");
124
+ cmd.fade = num;
125
+ }
126
+ return cmd;
127
+ }
128
+ case "audio_volume": {
129
+ const inst = tokens[0];
130
+ let volume: number | undefined;
131
+ for (const token of tokens.slice(1)) {
132
+ const eq = token.indexOf("=");
133
+ const key = eq < 0 ? token : token.slice(0, eq);
134
+ const raw = eq < 0 ? "" : token.slice(eq + 1);
135
+ if (key !== "volume") return fail(`audio_volume 未知参数:${token}`);
136
+ const num = Number(raw);
137
+ if (eq < 0 || raw === "" || Number.isNaN(num))
138
+ return fail("audio_volume 的 volume 需要数字");
139
+ volume = num;
140
+ }
141
+ if (inst === undefined) return fail("audio_volume 需要实例 id");
142
+ if (volume === undefined) return fail("audio_volume 需要 volume");
143
+ return { op: "audio_volume", inst, volume };
144
+ }
68
145
  // 视频族:resource 位置参数(video/cutscene);seek_video 为秒数
69
146
  case "video":
70
147
  case "cutscene": {
@@ -102,6 +179,18 @@ export function parseMediaStatement(c: StatementContext): StoryCommand | null {
102
179
  }
103
180
  case "pause_video":
104
181
  return { op: "pause_video" } as StoryCommand;
182
+ case "audio_pause":
183
+ case "audio_resume": {
184
+ // 位置参数可选:不写 channel = 全部常驻通道(se 除外),由执行期展开
185
+ const raw = tokens[0];
186
+ if (tokens.length > 1) return fail(`${op} 只接受一个 channel 参数`);
187
+ if (raw === undefined) return { op } as StoryCommand;
188
+ if (!isAudioChannel(raw))
189
+ return fail(
190
+ `${op} 的 channel 不合法:${raw}(须为 bgm / se / ambient / voice 之一)`,
191
+ );
192
+ return { op, channel: raw } as StoryCommand;
193
+ }
105
194
  case "resume_video":
106
195
  return { op: "resume_video" } as StoryCommand;
107
196
  case "stop_video":
@@ -0,0 +1,47 @@
1
+ /** ui 族的读向解析。 */
2
+ import { parseDictLiteral, extractKeyedDictLiteral } from "../../literals.ts";
3
+ import { splitTokens } from "../../lexer.ts";
4
+ import type { StoryCommand } from "../../../../contracts/index.ts";
5
+ import type { StatementContext } from "../state.ts";
6
+
7
+ /**
8
+ * 解析本族的 op 行(`mount`)。
9
+ *
10
+ * 入参是分发骨架建好的 `StatementContext`(已切好的词表、定位前缀、issue 出口与警告通道)。
11
+ * 只处理本族的 op,不匹配时落到末尾 `return null`,由骨架决定后续。
12
+ * 失败表现:不抛异常;语法或参数不符时用 `c.fail` 记一条带定位的 issue 并返回 `null`,
13
+ * 由调用方按 issues 非空整次拒绝。
14
+ *
15
+ * 形态:`mount <UI 名> <true|false> [props={…}]`。UI 名不带引号(短标识,与 `id=`/`name=`
16
+ * 等位置参同形);显隐只认字面 `true` / `false`——没有「切换」写法,避免文本形态与状态语义
17
+ * 各说一套。
18
+ */
19
+ export function parseUiStatement(c: StatementContext): StoryCommand | null {
20
+ const { op, rest, tokens, fail } = c;
21
+ switch (op) {
22
+ case "mount": {
23
+ const name = tokens[0];
24
+ if (name === undefined) return fail("mount 需要 UI 名");
25
+ const visible = tokens[1];
26
+ if (visible !== "true" && visible !== "false")
27
+ return fail("mount 需要 true|false(显隐必填,没有切换写法)");
28
+ const cmd: StoryCommand = {
29
+ op: "mount",
30
+ name,
31
+ visible: visible === "true",
32
+ };
33
+ const dict = extractKeyedDictLiteral(rest, "props");
34
+ if (dict !== null) {
35
+ const extra = splitTokens(dict.remainder).filter(
36
+ (t) => t !== name && t !== visible,
37
+ );
38
+ if (extra.length > 0) return fail(`mount 未知参数:${extra[0]}`);
39
+ cmd.props = parseDictLiteral(dict.literal);
40
+ return cmd;
41
+ }
42
+ if (tokens.length > 2) return fail(`mount 未知参数:${tokens[2]}`);
43
+ return cmd;
44
+ }
45
+ }
46
+ return null;
47
+ }
@@ -12,6 +12,7 @@ import {
12
12
  parseMediaStatement,
13
13
  parsePresentStatement,
14
14
  parseSaveStatement,
15
+ parseUiStatement,
15
16
  parseVariablesStatement,
16
17
  } from "./statement/index.ts";
17
18
  import { unquote } from "../lexer.ts";
@@ -97,6 +98,12 @@ export function parseSimpleStatement(
97
98
  case "stop_bgm":
98
99
  case "stop_ambient":
99
100
  case "stop_voice":
101
+ case "stop_se":
102
+ case "audio_play":
103
+ case "audio_stop":
104
+ case "audio_volume":
105
+ case "audio_pause":
106
+ case "audio_resume":
100
107
  case "video":
101
108
  case "cutscene":
102
109
  case "seek_video":
@@ -118,6 +125,8 @@ export function parseSimpleStatement(
118
125
  case "shake":
119
126
  case "text_typewriter":
120
127
  return parsePresentStatement(ctx);
128
+ case "mount":
129
+ return parseUiStatement(ctx);
121
130
  default:
122
131
  return fail(`文本投影暂不支持的语句:${op}`);
123
132
  }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * 内部按功能域归类,每个域有自己的出口,包出口只转发这些出口:
5
5
  * `contracts/`(契约,全量转发)、`runtime/index.ts`(执行器与运行期支撑)、
6
6
  * `data/index.ts`(故事文件解析·工程组装·文本投影·元素装载)。
7
- * 包出口不逐个深入实现文件;域内零件(`runtime/scope`、`runtime/resolver` 等)不经这里暴露。
7
+ * 包出口不逐个深入实现文件;域内零件(`runtime/scope`、`runtime/expr/resolver.ts` 等)不经这里暴露。
8
8
  */
9
9
  export * from "./contracts/index.ts";
10
10
  /**
@@ -35,7 +35,9 @@ export {
35
35
  collectGuardNames,
36
36
  collectTextProjections,
37
37
  collectWaitSpecs,
38
+ columnSummaries,
38
39
  createStateReader,
40
+ DEFAULT_AUDIO_CONFIG,
39
41
  DEFAULT_LAYER_Z,
40
42
  DEFAULT_SAVES_CONFIG,
41
43
  DEFAULT_STAGE_CONFIG,
@@ -53,10 +55,12 @@ export {
53
55
  manifestOrientation,
54
56
  mergeOverlayFiles,
55
57
  PlayerPreferences,
58
+ resolveAudioConfig,
56
59
  resolveHost,
57
60
  resolveInstanceZ,
58
61
  resolveLayerZ,
59
62
  resolveOrientationMode,
63
+ resolveProjectName,
60
64
  resolveSavesConfig,
61
65
  resolveStageConfig,
62
66
  slotIds,
@@ -64,6 +68,8 @@ export {
64
68
  WAITING_OPS,
65
69
  waitSpecOfOp,
66
70
  waitingStateOfOp,
71
+ directionOf,
72
+ type AudioConfig,
67
73
  type EngineOptions,
68
74
  type ExtensionModuleLoader,
69
75
  type GuardContext,
@@ -112,6 +118,7 @@ export {
112
118
  ProjectSerializationError,
113
119
  projectText,
114
120
  removeElements,
121
+ RESOURCES_DIR,
115
122
  serializeColumnDocument,
116
123
  serializeProject,
117
124
  STORIES_DIR,
@@ -13,6 +13,9 @@ import {
13
13
  execAssert,
14
14
  execAssign,
15
15
  execAudio,
16
+ execAudioInstance,
17
+ execAudioPause,
18
+ execAudioResume,
16
19
  execAutoSaveOp,
17
20
  execCall,
18
21
  execCharacter,
@@ -31,6 +34,7 @@ import {
31
34
  execLoadOp,
32
35
  execMenu,
33
36
  execMinigame,
37
+ execMount,
34
38
  execNavigate,
35
39
  execNotify,
36
40
  execNvl,
@@ -253,9 +257,24 @@ export function runDispatch(ctx: OpContext): void {
253
257
  case "stop_ambient":
254
258
  case "voice":
255
259
  case "stop_voice":
260
+ case "stop_se":
256
261
  if (!execAudio(ctx, cmd)) return;
257
262
  frame.index += 1;
258
263
  continue;
264
+ case "audio_play":
265
+ case "audio_stop":
266
+ case "audio_volume":
267
+ if (!execAudioInstance(ctx, cmd)) return;
268
+ frame.index += 1;
269
+ continue;
270
+ case "audio_pause":
271
+ if (!execAudioPause(ctx, cmd)) return;
272
+ frame.index += 1;
273
+ continue;
274
+ case "audio_resume":
275
+ if (!execAudioResume(ctx, cmd)) return;
276
+ frame.index += 1;
277
+ continue;
259
278
  case "video":
260
279
  case "seek_video":
261
280
  case "pause_video":
@@ -306,6 +325,11 @@ export function runDispatch(ctx: OpContext): void {
306
325
  if (!execTextTypewriter(ctx, cmd)) return;
307
326
  frame.index += 1;
308
327
  continue;
328
+ case "mount":
329
+ // 挂载 UI 显隐:写 SYS.mounts(宿主据此挂载/卸载渲染树上的组件)
330
+ if (!execMount(ctx, cmd)) return;
331
+ frame.index += 1;
332
+ continue;
309
333
  default:
310
334
  // fail-closed:未知/未实现 op 不静默跳过
311
335
  ctx.fail("unknown-op", `未知或未实现的命令:${cmd.op}`);
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 列清单投影:故事定义 → 宿主可读的只读摘要。
3
+ *
4
+ * **为什么要这一层而不是让宿主读 `story.columns`**:宿主一旦拿到整份 `StoryColumn`,
5
+ * 就能拿到 `commands` / `elements` / `entry`——那是引擎的解释输入。宿主自己解释一遍
6
+ * 命令流,等于在引擎之外再造一套解释器,两套必然漂移。这里只交出宿主真正用得上的
7
+ * 三件事:列 id、归一后的类型、内容形态。
8
+ *
9
+ * **写时复制**:每次调用都新建数组与对象并冻结。摘要一旦可写,宿主改一个 `type`
10
+ * 就会让「引擎认为的列类型」和「宿主认为的」分叉,而引擎那边毫不知情。
11
+ * 冻结把这种改动静默变成立即报错(严格模式下赋值抛 TypeError)。
12
+ */
13
+ import { sceneTypeOf } from "../../shared/guards.ts";
14
+ import type { ColumnSummary } from "../../contracts/runtime/column-summary.ts";
15
+ import type { Story } from "../../contracts/story/entities.ts";
16
+
17
+ /**
18
+ * 造一份列摘要表(顺序与 `story.columns` 一致)。
19
+ *
20
+ * 顺序即呈现顺序:宿主列章节时按这个次序,跳转与「下一列」也据此推断,
21
+ * 所以**不排序、不去重**——故事里的顺序就是作者写的顺序。
22
+ */
23
+ export function columnSummaries(story: Story): readonly ColumnSummary[] {
24
+ return story.columns.map((column) =>
25
+ Object.freeze<ColumnSummary>({
26
+ id: column.id,
27
+ type: sceneTypeOf(column),
28
+ kind: column.kind,
29
+ }),
30
+ );
31
+ }
@@ -63,7 +63,7 @@ import {
63
63
  restoreSaveExtensions,
64
64
  tryMigrateSave,
65
65
  } from "../save/index.ts";
66
- import { type NameResolver } from "../resolver.ts";
66
+ import { type NameResolver } from "../expr/resolver.ts";
67
67
  import type {
68
68
  ColumnCoordinate,
69
69
  ElementInstance,
@@ -204,9 +204,12 @@ export abstract class EngineForwarders extends EngineCore {
204
204
  protected columnFrame(): Frame | undefined {
205
205
  return columnFrame(this.ctx);
206
206
  }
207
- /** 内部转发 → ./internal:按坐标取执行帧(回溯重建用) */
208
- protected columnFrameAt(coord: ColumnCoordinate): Frame {
209
- return columnFrameAt(this.ctx, coord);
207
+ /** 内部转发 → ./internal:按坐标取执行帧(回溯重建用);`scope` 给定时作为列级层内容 */
208
+ protected columnFrameAt(
209
+ coord: ColumnCoordinate,
210
+ scope?: readonly (readonly [string, unknown])[],
211
+ ): Frame {
212
+ return columnFrameAt(this.ctx, coord, scope);
210
213
  }
211
214
  /** 内部转发 → ./save:旧版本存档迁移;未注入迁移钩子时拒绝 */
212
215
  protected tryMigrateSave(data: SaveDataV1): SaveDataV1 | null {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 外部玩法系统的状态写入面:把「作用域化键」与「是否触发观察事件」的四种组合
3
+ * 收成 `GameStateWriter` 契约要求的五个方法。
4
+ *
5
+ * 两个入参是引擎侧的两条既有通道(外部写入 + 只读取值),这里只做组合,
6
+ * 不复制键校验与值契约——那两条规则的唯一实现在 `../state/writer.ts`。
7
+ */
8
+ import { gameScopedKey, type GameStateWriter } from "../../contracts/index.ts";
9
+
10
+ export function createGameState(
11
+ write: (
12
+ key: string,
13
+ value: unknown,
14
+ emitChange: boolean,
15
+ systemId?: string,
16
+ ) => boolean,
17
+ read: (key: string) => unknown,
18
+ ): GameStateWriter {
19
+ return {
20
+ set: (key: string, value: unknown): boolean => write(key, value, true),
21
+ setSilent: (key: string, value: unknown): boolean =>
22
+ write(key, value, false),
23
+ setScoped: (systemId: string, key: string, value: unknown): boolean =>
24
+ write(gameScopedKey(systemId, key), value, true, systemId),
25
+ setScopedSilent: (systemId: string, key: string, value: unknown): boolean =>
26
+ write(gameScopedKey(systemId, key), value, false, systemId),
27
+ get: (key: string): unknown => read(key),
28
+ };
29
+ }
@@ -6,3 +6,5 @@
6
6
  */
7
7
  export { EngineCore } from "./state.ts";
8
8
  export { EngineForwarders } from "./forwarders.ts";
9
+ export { createGameState } from "./game-state.ts";
10
+ export { columnSummaries } from "./columns.ts";
@@ -9,6 +9,7 @@
9
9
  * 改字段含义看本层;改行为去 `./internal`、`./state`、`./history`、`./save`、`./ops`、`./i18n`。
10
10
  */
11
11
  import type {
12
+ AudioConfig,
12
13
  CharacterDef,
13
14
  ColumnCoordinate,
14
15
  EngineOptions,
@@ -27,7 +28,11 @@ import type {
27
28
  GuardFn,
28
29
  SaveDataV1,
29
30
  } from "../../contracts/index.ts";
30
- import { defaultEngineTexts, hostTimer } from "../../contracts/index.ts";
31
+ import {
32
+ DEFAULT_AUDIO_CONFIG,
33
+ defaultEngineTexts,
34
+ hostTimer,
35
+ } from "../../contracts/index.ts";
31
36
  import {
32
37
  buildMinigameRegistry,
33
38
  buildOpRegistry,
@@ -59,6 +64,10 @@ export class EngineCore {
59
64
  protected started = false;
60
65
  /** 等待型命令的定时器句柄;换列、回溯、读档、热重载、销毁时必须清掉,否则会推进已失效的等待 */
61
66
  protected pendingTimer: TimerHandle | null = null;
67
+ /** 挂起定时器的到期时刻;`clear` 之后句柄再也问不出剩余量,只能靠它算 */
68
+ protected pendingTimerDueAt: number | null = null;
69
+ /** 暂停时挂起定时器的剩余毫秒;恢复按它续上原来的节奏,`null` = 无待续起定时器 */
70
+ protected pausedTimerRemainingMs: number | null = null;
62
71
  /** 定时器端口(wait/pause 的延迟回调来源;缺省 hostTimer = 宿主全局定时器) */
63
72
  protected readonly timerPort: TimerPort;
64
73
  /** 用户可见文案表(缺省 defaultEngineTexts;宿主经 EngineOptions.texts 部分覆盖) */
@@ -136,6 +145,8 @@ export class EngineCore {
136
145
  protected savePort: SavePort | undefined;
137
146
  /** 存档模式:决定存档可否跨设备读取,也决定 DEK 封装方式(本机绑定 = 用 OS 凭据封存;可携带 = 明文随档);构造期注入,运行期不变 */
138
147
  protected saveMode: SaveMode;
148
+ /** 工程默认音量(非状态:来自 project.json,不进存档 / 不进快照 / 不进回溯) */
149
+ protected audioConfig: AudioConfig;
139
150
  /** save op 的一次性存档声明:载荷在下一玩家所见等待画面落档(coord = 等待点) */
140
151
  protected pendingSave: { slot: string; title?: string } | null = null;
141
152
  /** I18N 端口与当前语言译文表(null = 原文直出;切换 = 整表重建并清缓存) */
@@ -181,6 +192,7 @@ export class EngineCore {
181
192
  this.rngState = (options?.rngSeed ?? Date.now()) | 0;
182
193
  this.savePort = options?.savePort;
183
194
  this.saveMode = options?.saveMode ?? "machine-bound";
195
+ this.audioConfig = options?.audioConfig ?? DEFAULT_AUDIO_CONFIG;
184
196
  this.i18nPort = options?.i18nPort;
185
197
  this.timerPort = options?.timerPort ?? hostTimer;
186
198
  this.texts = { ...defaultEngineTexts, ...options?.texts };
@@ -7,6 +7,7 @@ import type {
7
7
  AnimationSpec,
8
8
  CharacterDef,
9
9
  ColumnCoordinate,
10
+ ColumnSummary,
10
11
  ElementInstance,
11
12
  EngineOptions,
12
13
  EventListener,
@@ -17,7 +18,6 @@ import type {
17
18
  SaveOptions,
18
19
  } from "../contracts/index.ts";
19
20
  import {
20
- gameScopedKey,
21
21
  type ExtensionOpResult,
22
22
  type GameStateWriter,
23
23
  type InteractionResult,
@@ -35,8 +35,11 @@ import {
35
35
  historyLength,
36
36
  historyView,
37
37
  rollbackTo,
38
+ type HistoryViewEntry,
38
39
  } from "./history/index.ts";
39
40
  import { exportSave, importSave } from "./save/index.ts";
41
+ import { pauseSession, resumeSession } from "./session/index.ts";
42
+ import { columnSummaries } from "./engine/columns.ts";
40
43
  import {
41
44
  advance,
42
45
  animationFinished,
@@ -53,6 +56,7 @@ import {
53
56
  navigate,
54
57
  onEvent,
55
58
  onStateChanged,
59
+ pausedSession,
56
60
  reloadStory,
57
61
  reportMediaPosition,
58
62
  save,
@@ -63,7 +67,7 @@ import {
63
67
  videoFinished,
64
68
  type OpContext,
65
69
  } from "./internal/index.ts";
66
- import { EngineForwarders } from "./engine/index.ts";
70
+ import { EngineForwarders, createGameState } from "./engine/index.ts";
67
71
 
68
72
  /**
69
73
  * 装配契约(构造选项与守卫)定义在契约层,此处按原路径转出,
@@ -168,6 +172,8 @@ export class StoryEngine extends EngineForwarders {
168
172
  this.eventListeners,
169
173
  this.started,
170
174
  this.pendingTimer,
175
+ this.pendingTimerDueAt,
176
+ this.pausedTimerRemainingMs,
171
177
  this.waitSkipable,
172
178
  this.timerPort,
173
179
  this.texts,
@@ -179,6 +185,8 @@ export class StoryEngine extends EngineForwarders {
179
185
  this.videoSeq,
180
186
  this.savePort,
181
187
  this.saveMode,
188
+ // 工程级配置(非状态)
189
+ this.audioConfig,
182
190
  this.i18nPort,
183
191
  this.clearTimer,
184
192
  this.abortExternalTakeovers,
@@ -231,6 +239,19 @@ export class StoryEngine extends EngineForwarders {
231
239
  return this.state.get(key);
232
240
  }
233
241
 
242
+ // —— 列清单 ——
243
+
244
+ /**
245
+ * 故事列清单的只读摘要(顺序同故事定义):宿主据此列章节、判当前页、决定跳哪。
246
+ *
247
+ * **每次调用都返回新数组**,元素是冻结对象——调用方随手 `push` / 改字段都碰不到引擎。
248
+ * 不含 `commands` / `elements` / `entry`:那部分归引擎解释,宿主不应自己再解释一遍。
249
+ * 热重载后重新调用即得新清单(不要缓存返回值跨重载使用)。
250
+ */
251
+ columns(): readonly ColumnSummary[] {
252
+ return columnSummaries(this.story);
253
+ }
254
+
234
255
  // —— 命令面 ——
235
256
  // 公开方法都是转交给实现模块的薄壳,语义与错误处理以目标模块为准;夹在其中的
236
257
  // private 成员是同一转发模式的内部版本,供迁出的自由函数经 ctx 回调本类私有面。
@@ -267,6 +288,23 @@ export class StoryEngine extends EngineForwarders {
267
288
  dispose(): void {
268
289
  dispose(this.ctx);
269
290
  }
291
+ /**
292
+ * 暂停:挂起等待定时器、让正在出声的媒体收声、记下暂停标记。
293
+ *
294
+ * 幂等。**不中断外部接管**(小游戏 / 玩法系统 / 扩展 op 等待保持挂载,只是不再收到推进输入),
295
+ * 否则再恢复就不是「接着玩」而是「重进」。暂停期间 `advance()` / `choose()` fail-closed。
296
+ */
297
+ pause(): void {
298
+ pauseSession(this.ctx);
299
+ }
300
+ /** 解除暂停:按剩余毫秒续上原来的等待,媒体按当前状态逐个恢复。未暂停时幂等空操作 */
301
+ resume(): void {
302
+ resumeSession(this.ctx);
303
+ }
304
+ /** 是否处于暂停中 */
305
+ paused(): boolean {
306
+ return pausedSession(this.ctx);
307
+ }
270
308
  /** 当前舞台上的元素实例列表,供宿主渲染 */
271
309
  elements(): ElementInstance[] {
272
310
  return elements(this.ctx);
@@ -296,17 +334,11 @@ export class StoryEngine extends EngineForwarders {
296
334
  return resolveExtensionOp(this.ctx, op, result);
297
335
  }
298
336
  /** 外部玩法系统的状态写入面:作用域化键与「是否触发观察事件」的四种组合 */
299
- readonly gameState: GameStateWriter = {
300
- set: (key: string, value: unknown): boolean =>
301
- this.writeExternal(key, value, true),
302
- setSilent: (key: string, value: unknown): boolean =>
303
- this.writeExternal(key, value, false),
304
- setScoped: (systemId: string, key: string, value: unknown): boolean =>
305
- this.writeExternal(gameScopedKey(systemId, key), value, true, systemId),
306
- setScopedSilent: (systemId: string, key: string, value: unknown): boolean =>
307
- this.writeExternal(gameScopedKey(systemId, key), value, false, systemId),
308
- get: (key: string): unknown => this.state.get(key),
309
- };
337
+ readonly gameState: GameStateWriter = createGameState(
338
+ (key, value, emitChange, systemId) =>
339
+ this.writeExternal(key, value, emitChange, systemId),
340
+ (key) => this.state.get(key),
341
+ );
310
342
  /** 查角色定义(character op 注册;say 的 speaker 按它套样式) */
311
343
  getCharacter(key: string): CharacterDef | undefined {
312
344
  return getCharacter(this.ctx, key);
@@ -376,14 +408,7 @@ export class StoryEngine extends EngineForwarders {
376
408
  reloadStory(this.ctx, story);
377
409
  }
378
410
  /** 历史浏览视图:逐条给出序号、坐标、说话人与文本(nvl 另带多行) */
379
- historyView(): Array<{
380
- index: number;
381
- coord: ColumnCoordinate;
382
- speaker: string;
383
- text: string;
384
- nvl: boolean;
385
- nvlLines: string[];
386
- }> {
411
+ historyView(): HistoryViewEntry[] {
387
412
  return historyView(this.ctx);
388
413
  }
389
414
  }
@@ -6,7 +6,7 @@
6
6
  * (配合 `??` 兜底),路径存在但形态不符仍 fail-closed;未知函数 fail-closed。
7
7
  */
8
8
  import { ExpressionError, type ExprValue } from "./value.ts";
9
- import type { NameResolver } from "../resolver.ts";
9
+ import type { NameResolver } from "./resolver.ts";
10
10
 
11
11
  /** 内建函数全集:random/min/max/abs/clamp(算术)+ arrayGet/dictGet/optGet/len(路径读取) */
12
12
  export function callBuiltin(
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 求值入口:解析 + 求值一步完成(求值时机 = 执行到它的时刻)。
3
3
  */
4
- import type { NameResolver } from "../resolver.ts";
4
+ import type { NameResolver } from "./resolver.ts";
5
5
  import { tokenize } from "./lexer.ts";
6
6
  import { Parser } from "./parser/index.ts";
7
7
  import { ExpressionError, type ExprValue } from "./value.ts";
@@ -12,4 +12,4 @@ export {
12
12
  } from "./value.ts";
13
13
  export { evaluateExpression } from "./evaluate.ts";
14
14
  export { interpolateText, type TextInterpolation } from "./interpolate.ts";
15
- export type { NameResolver } from "../resolver.ts";
15
+ export type { NameResolver } from "./resolver.ts";
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 行内富文本标记原样透传给渲染层;插值失败保留原文片段并收集错误。
5
5
  */
6
- import type { NameResolver } from "../resolver.ts";
6
+ import type { NameResolver } from "./resolver.ts";
7
7
  import { evaluateExpression } from "./evaluate.ts";
8
8
  import { ExpressionError, type ExprValue } from "./value.ts";
9
9
 
@@ -5,7 +5,7 @@
5
5
  * 游标把「只有这里能改 pos」收在一处;peek 在越界时钳到末尾的 eof,
6
6
  * 所以上层各层不必各自判边界。
7
7
  */
8
- import type { NameResolver } from "../../resolver.ts";
8
+ import type { NameResolver } from "../resolver.ts";
9
9
  import type { Token } from "../lexer.ts";
10
10
  import { ExpressionError, type ExprValue } from "../value.ts";
11
11
 
@@ -24,3 +24,4 @@ export {
24
24
  lastReplayableCursor,
25
25
  rollbackTo,
26
26
  } from "./rollback.ts";
27
+ export type { HistoryViewEntry } from "./rollback.ts";