@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.
Files changed (90) 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 +41 -1
  12. package/src/contracts/runtime/state-keys.ts +70 -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/format/validate/dialog.ts +32 -3
  19. package/src/data/index.ts +1 -0
  20. package/src/data/project/assemble.ts +37 -6
  21. package/src/data/project/conflict.ts +4 -3
  22. package/src/data/project/diff.ts +3 -3
  23. package/src/data/project/index.ts +6 -1
  24. package/src/data/project/naming.ts +11 -5
  25. package/src/data/project/serialize.ts +49 -16
  26. package/src/data/text/entry.ts +17 -9
  27. package/src/data/text/generate/command/dialog.ts +15 -3
  28. package/src/data/text/generate/command/index.ts +2 -1
  29. package/src/data/text/generate/command/media.ts +33 -2
  30. package/src/data/text/generate/command/ui.ts +29 -0
  31. package/src/data/text/generate/command.ts +10 -0
  32. package/src/data/text/literals.ts +140 -0
  33. package/src/data/text/parse/statement/dialog.ts +19 -0
  34. package/src/data/text/parse/statement/flow.ts +1 -1
  35. package/src/data/text/parse/statement/index.ts +2 -1
  36. package/src/data/text/parse/statement/media.ts +91 -2
  37. package/src/data/text/parse/statement/ui.ts +47 -0
  38. package/src/data/text/parse/statement.ts +9 -0
  39. package/src/index.ts +8 -1
  40. package/src/runtime/dispatch.ts +24 -0
  41. package/src/runtime/engine/columns.ts +31 -0
  42. package/src/runtime/engine/forwarders.ts +7 -4
  43. package/src/runtime/engine/game-state.ts +29 -0
  44. package/src/runtime/engine/index.ts +2 -0
  45. package/src/runtime/engine/state.ts +13 -1
  46. package/src/runtime/engine.ts +46 -21
  47. package/src/runtime/expr/builtins.ts +1 -1
  48. package/src/runtime/expr/evaluate.ts +1 -1
  49. package/src/runtime/expr/index.ts +1 -1
  50. package/src/runtime/expr/interpolate.ts +1 -1
  51. package/src/runtime/expr/parser/cursor.ts +1 -1
  52. package/src/runtime/history/index.ts +1 -0
  53. package/src/runtime/history/rollback.ts +23 -8
  54. package/src/runtime/index.ts +9 -3
  55. package/src/runtime/internal/context.ts +25 -2
  56. package/src/runtime/internal/helpers.ts +18 -0
  57. package/src/runtime/internal/index.ts +9 -1
  58. package/src/runtime/internal/session.ts +11 -0
  59. package/src/runtime/internal/waiting.ts +51 -3
  60. package/src/runtime/opRegistry.ts +7 -0
  61. package/src/runtime/ops/dialog.ts +2 -11
  62. package/src/runtime/ops/flow.ts +1 -1
  63. package/src/runtime/ops/index.ts +11 -1
  64. package/src/runtime/ops/interaction.ts +14 -1
  65. package/src/runtime/ops/media/audio.ts +230 -0
  66. package/src/runtime/ops/media/index.ts +18 -0
  67. package/src/runtime/ops/media/instances.ts +312 -0
  68. package/src/runtime/ops/media/notify.ts +32 -0
  69. package/src/runtime/ops/media/shared.ts +133 -0
  70. package/src/runtime/ops/media/video.ts +118 -0
  71. package/src/runtime/ops/ui.ts +95 -0
  72. package/src/runtime/paramDirection.ts +35 -0
  73. package/src/runtime/save/export.ts +52 -6
  74. package/src/runtime/save/import.ts +21 -6
  75. package/src/runtime/scope.ts +17 -0
  76. package/src/runtime/session/index.ts +21 -0
  77. package/src/runtime/session/pause.ts +114 -0
  78. package/src/runtime/{waitingOps.ts → session/waitingOps.ts} +1 -1
  79. package/src/runtime/shell.ts +68 -0
  80. package/src/runtime/state/index.ts +2 -1
  81. package/src/runtime/state/reader.ts +47 -1
  82. package/src/runtime/state/writer.ts +1 -1
  83. package/src/shared/audio.ts +106 -0
  84. package/src/shared/guards.ts +38 -4
  85. package/src/shared/index.ts +3 -0
  86. package/src/shared/mounts.ts +42 -0
  87. package/src/shared/ui-name.ts +41 -0
  88. package/src/runtime/ops/media.ts +0 -274
  89. /package/src/runtime/{resolver.ts → expr/resolver.ts} +0 -0
  90. /package/src/runtime/{stateContract.ts → state/stateContract.ts} +0 -0
@@ -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";
@@ -53,14 +53,24 @@ export function columnFrame(ctx: OpContext): Frame | undefined {
53
53
  return undefined;
54
54
  }
55
55
 
56
- /** 按坐标重建列帧(存档不进帧栈——块/列级作用域不进档,读档后确定性重放重建) */
57
- export function columnFrameAt(ctx: OpContext, coord: ColumnCoordinate): Frame {
56
+ /**
57
+ * 按坐标重建列帧。
58
+ *
59
+ * 列级作用域由调用方给:读档时必须带上存档里的 `scope`(列级声明都在存档坐标
60
+ * 之前,重放不会回到那些命令);回溯用检查点自带的帧栈,不走这里。
61
+ * 块级帧一律不重建——它们由重放重入块体重建。
62
+ */
63
+ export function columnFrameAt(
64
+ ctx: OpContext,
65
+ coord: ColumnCoordinate,
66
+ scope?: readonly (readonly [string, unknown])[],
67
+ ): Frame {
58
68
  const column = ctx.columnById(coord.columnId)!;
59
69
  return {
60
70
  columnId: coord.columnId,
61
71
  commands: column.kind === "flow" ? column.commands! : (column.entry ?? []),
62
72
  index: coord.index,
63
- scope: Scope.root(),
73
+ scope: scope === undefined ? Scope.root() : Scope.fromEntries(scope),
64
74
  };
65
75
  }
66
76
 
@@ -168,18 +178,23 @@ export function forward(ctx: OpContext): void {
168
178
  }
169
179
 
170
180
  /**
171
- * 历史面板数据(前端职责的可视化皮):对话类检查点带说话者与文本;
172
- * NVL 检查点额外带 `nvl` 标记与**累积行快照**(nvlLines = 该时刻玩家已见的整块文本)——
173
- * 宿主按「段聚合」呈现(历史不灌水),回溯粒度不变(逐检查点仍全在 history 里)。
181
+ * 历史面板的一条记录:对话类检查点带说话者与文本;
182
+ * NVL 检查点额外带 `nvl` 标记与**累积行快照**(nvlLines = 该时刻玩家已见的整块文本)。
174
183
  */
175
- export function historyView(ctx: OpContext): Array<{
184
+ export interface HistoryViewEntry {
176
185
  index: number;
177
186
  coord: ColumnCoordinate;
178
187
  speaker: string;
179
188
  text: string;
180
189
  nvl: boolean;
181
190
  nvlLines: string[];
182
- }> {
191
+ }
192
+
193
+ /**
194
+ * 历史面板数据(前端职责的可视化皮):宿主按「段聚合」呈现(历史不灌水),
195
+ * 回溯粒度不变(逐检查点仍全在 history 里)。
196
+ */
197
+ export function historyView(ctx: OpContext): HistoryViewEntry[] {
183
198
  return ctx.history.map((cp, index) => {
184
199
  const mode = cp.snapshot.state.find(([k]) => k === SYS.nvlMode)?.[1];
185
200
  const buffer = cp.snapshot.state.find(([k]) => k === SYS.nvlBuffer)?.[1];
@@ -3,11 +3,12 @@
3
3
  * 写入契约守卫 / op 注册表 / 等待声明表 / 扩展装载 / 表达式求值 / 状态读口)。
4
4
  *
5
5
  * 这是包出口(`src/index.ts`)取运行层符号的唯一入口——包出口不再逐个深入实现文件;
6
- * 运行层内部各模块之间仍按需直接引用(`./resolver` 属内部零件,不经此出口;
6
+ * 运行层内部各模块之间仍按需直接引用(`./expr/resolver.ts` 属表达式域内部零件;
7
7
  * `./scope` 供 `tests/engine/runtime/scope.test.ts` 的白盒用例经域出口取用)。
8
8
  */
9
9
  export { StoryEngine } from "./engine.ts";
10
10
  export type { EngineOptions, GuardContext, GuardFn } from "./engine.ts";
11
+ export { columnSummaries } from "./engine/index.ts";
11
12
  export { isValidSayColor } from "./ops/index.ts";
12
13
  export { resolveHost } from "./host.ts";
13
14
  export { mergeOverlayFiles } from "./i18n/index.ts";
@@ -15,6 +16,7 @@ export { PlayerPreferences } from "./preferences.ts";
15
16
  export { createStateReader } from "./state/index.ts";
16
17
  export { Scope } from "./scope.ts";
17
18
  export {
19
+ DEFAULT_AUDIO_CONFIG,
18
20
  DEFAULT_LAYER_Z,
19
21
  DEFAULT_SAVES_CONFIG,
20
22
  DEFAULT_STAGE_CONFIG,
@@ -22,14 +24,17 @@ export {
22
24
  instanceZLayer,
23
25
  LAYER_IDS,
24
26
  manifestOrientation,
27
+ resolveAudioConfig,
25
28
  resolveInstanceZ,
26
29
  resolveLayerZ,
27
30
  resolveOrientationMode,
31
+ resolveProjectName,
28
32
  resolveSavesConfig,
29
33
  resolveStageConfig,
30
34
  slotIds,
31
35
  } from "./shell.ts";
32
36
  export type {
37
+ AudioConfig,
33
38
  LayerId,
34
39
  LayerZOverrides,
35
40
  LayerZTable,
@@ -40,7 +45,7 @@ export type {
40
45
  StageFit,
41
46
  StageScalePolicy,
42
47
  } from "./shell.ts";
43
- export { findJsonValueError, jsonUnsafeReason } from "./stateContract.ts";
48
+ export { findJsonValueError, jsonUnsafeReason } from "./state/index.ts";
44
49
  export { collectGuardNames, findMissingGuards } from "./guardNames.ts";
45
50
  export {
46
51
  BUILTIN_OP_NAMES,
@@ -53,7 +58,7 @@ export {
53
58
  WAITING_OPS,
54
59
  waitingStateOfOp,
55
60
  type WaitSpec,
56
- } from "./waitingOps.ts";
61
+ } from "./session/index.ts";
57
62
  export {
58
63
  loadDeclaredExtensions,
59
64
  type ExtensionModuleLoader,
@@ -64,3 +69,4 @@ export {
64
69
  interpolateText,
65
70
  type NameResolver,
66
71
  } from "./expr/index.ts";
72
+ export { directionOf } from "./paramDirection.ts";
@@ -16,6 +16,7 @@
16
16
  * 本接口只在运行层内部流转,不进包出口。
17
17
  */
18
18
  import type {
19
+ AudioConfig,
19
20
  EngineTexts,
20
21
  I18nPort,
21
22
  AnimationSpec,
@@ -59,6 +60,11 @@ export interface OpContext {
59
60
  /** 写系统键(不进用户存档) */
60
61
  setSystem(key: string, value: unknown): void;
61
62
 
63
+ // —— 工程级配置(非状态:来自 project.json,不进存档) ——
64
+
65
+ /** 工程默认音量(`shell.audio.volumes`,组装期解析;命令未写 volume 时取它) */
66
+ audioConfig: AudioConfig;
67
+
62
68
  // —— 帧栈与坐标 ——
63
69
 
64
70
  frames: Frame[];
@@ -73,8 +79,11 @@ export interface OpContext {
73
79
  columnById(id: string): StoryColumn | undefined;
74
80
  /** 当前列帧(自栈顶向下第一个列帧) */
75
81
  columnFrame(): Frame | undefined;
76
- /** 按坐标造一个「未执行」的列帧(读档重建用) */
77
- columnFrameAt(coord: ColumnCoordinate): Frame;
82
+ /** 按坐标造一个「未执行」的列帧(读档/回溯重建用);`scope` 给定时作为列级层内容 */
83
+ columnFrameAt(
84
+ coord: ColumnCoordinate,
85
+ scope?: readonly (readonly [string, unknown])[],
86
+ ): Frame;
78
87
  /** 进入一条列:重置帧栈、装载元素、记账菜单返回点 */
79
88
  enterColumn(columnId: string): boolean;
80
89
 
@@ -177,6 +186,20 @@ export interface OpContext {
177
186
  started: boolean;
178
187
  waitSkipable: boolean;
179
188
  pendingTimer: TimerHandle | null;
189
+ /**
190
+ * 挂起定时器的到期时刻(毫秒时间轴)。
191
+ *
192
+ * 定时器一旦被 `clear` 就无法再问「还剩多久」,所以起定时器时把到期时刻记下来,
193
+ * 暂停时用它与当前时刻相减得到剩余量。`null` = 当前没有在跑的等待定时器。
194
+ */
195
+ pendingTimerDueAt: number | null;
196
+ /**
197
+ * 暂停时挂起定时器的剩余毫秒。
198
+ *
199
+ * 恢复要接着原来的节奏走,而不是从头再等一遍;`null` = 没有待续起的定时器
200
+ * (未暂停、定时器已到期、或导航/读档/回溯已把它丢弃)。
201
+ */
202
+ pausedTimerRemainingMs: number | null;
180
203
  inputStore: string | null;
181
204
  /** 清掉等待定时器 */
182
205
  clearTimer(): void;
@@ -68,6 +68,24 @@ export function parseMenuReturn(
68
68
  };
69
69
  }
70
70
 
71
+ /**
72
+ * 解析存档里的列级作用域(`SaveDataV1.scope`)。
73
+ *
74
+ * **fail-closed**:形状不符 ⇒ 返回 `null`(调用方按空列级层处理,不中断读档——
75
+ * 旧档本来就没有这个字段,把它当错误会让存量档读不了)。
76
+ */
77
+ export function parseScopeEntries(raw: unknown): [string, unknown][] | null {
78
+ if (!Array.isArray(raw)) return null;
79
+ const out: [string, unknown][] = [];
80
+ for (const item of raw) {
81
+ if (!Array.isArray(item) || item.length !== 2) return null;
82
+ const [key, value] = item as [unknown, unknown];
83
+ if (typeof key !== "string" || key === "") return null;
84
+ out.push([key, value]);
85
+ }
86
+ return out;
87
+ }
88
+
71
89
  /** 快照状态中的字符串键值(缺省/非字符串 = 空串) */
72
90
  export function snapshotText(cp: Checkpoint, key: string): string {
73
91
  const found = cp.snapshot.state.find(([k]) => k === key)?.[1];
@@ -7,6 +7,7 @@ export {
7
7
  cloneFrame,
8
8
  GuardFailure,
9
9
  parseMenuReturn,
10
+ parseScopeEntries,
10
11
  sameCoord,
11
12
  snapshotText,
12
13
  validSlot,
@@ -31,7 +32,14 @@ export {
31
32
  setLanguage,
32
33
  start,
33
34
  } from "./session.ts";
34
- export { abortExternalTakeovers, clearTimer, input } from "./waiting.ts";
35
+ export {
36
+ abortExternalTakeovers,
37
+ armWaitTimer,
38
+ clearTimer,
39
+ input,
40
+ nowMs,
41
+ pausedSession,
42
+ } from "./waiting.ts";
35
43
  export {
36
44
  animationFinished,
37
45
  animations,
@@ -18,6 +18,7 @@ import {
18
18
  } from "../../contracts/index.ts";
19
19
  import { mergeOverlayFiles } from "../i18n/index.ts";
20
20
  import { validSlot } from "./helpers.ts";
21
+ import { pausedSession } from "./waiting.ts";
21
22
  import type { OpContext } from "./context.ts";
22
23
 
23
24
  /**
@@ -53,6 +54,10 @@ export function advance(ctx: OpContext): void {
53
54
  ctx.fail("advance-invalid", "故事尚未启动");
54
55
  return;
55
56
  }
57
+ if (pausedSession(ctx)) {
58
+ ctx.fail("pause-active", "引擎处于暂停中,advance 被拒绝");
59
+ return;
60
+ }
56
61
  const waiting = ctx.get(SYS.waiting);
57
62
  if (waiting === "dialog") {
58
63
  ctx.setSystem(SYS.dialogComplete, true);
@@ -128,6 +133,10 @@ export function choose(ctx: OpContext, optionId: string): void {
128
133
  );
129
134
  return;
130
135
  }
136
+ if (pausedSession(ctx)) {
137
+ ctx.fail("pause-active", "引擎处于暂停中,choose 被拒绝");
138
+ return;
139
+ }
131
140
  const targets = ctx.get(SYS.menuTargets);
132
141
  if (!Array.isArray(targets)) {
133
142
  ctx.fail("menu-state-corrupt", "__menu_targets 缺失或非数组");
@@ -279,6 +288,8 @@ export function dispose(ctx: OpContext): void {
279
288
  ctx.clearTimer();
280
289
  ctx.abortExternalTakeovers(); // 挂载 signal abort → 宿主卸载外部接管
281
290
  ctx.waitSkipable = false;
291
+ // 暂停标记随销毁一起退场:留下真值会让复用同一实例的宿主继续看到「暂停中」
292
+ if (pausedSession(ctx)) ctx.setSystem(SYS.paused, false);
282
293
  }
283
294
 
284
295
  /**
@@ -1,6 +1,7 @@
1
1
  /**
2
- * 等待态零件:输入回填、定时器清理与外部接管中断。
3
- * 等待的建立分散在各自的命令处理器里,这里只收解除侧。
2
+ * 等待态零件:输入回填、等待定时器的建立与清理、外部接管中断。
3
+ * 等待的建立分散在各自的命令处理器里,这里收「定时器」这一件的两端(建立与解除)
4
+ * 以及解除侧的回填与中断。
4
5
  *
5
6
  * 只在运行层内部使用,不进包出口。
6
7
  */
@@ -31,12 +32,59 @@ export function input(ctx: OpContext, value: string): void {
31
32
  ctx.run();
32
33
  }
33
34
 
34
- /** 清掉挂起的等待定时器(导航、销毁、读档与回溯前调用,避免旧计时器醒来改状态) */
35
+ /**
36
+ * 当前时刻(毫秒时间轴)。
37
+ *
38
+ * 走定时器端口而不是直接读宿主时钟:没有 JS 运行时的宿主(原生编译目标)
39
+ * 可以自己给时刻,核心就不必假设全局 `Date` 存在。
40
+ */
41
+ export function nowMs(ctx: OpContext): number {
42
+ return ctx.timerPort.now?.() ?? Date.now();
43
+ }
44
+
45
+ /**
46
+ * 安排等待定时器:到点解除 wait 等待并继续执行。
47
+ *
48
+ * 回调体与等待建立是同一件事,所以建立与「暂停后按剩余量重排」共用本函数——
49
+ * 两处各写一份的话,漏掉 `__waiting` 判定就会推进一个已经结束的等待。
50
+ * 到期时刻一并记账,暂停靠它算出还剩多少毫秒。
51
+ */
52
+ export function armWaitTimer(ctx: OpContext, ms: number): void {
53
+ ctx.pendingTimerDueAt = nowMs(ctx) + ms;
54
+ ctx.pendingTimer = ctx.timerPort.set(() => {
55
+ ctx.pendingTimer = null;
56
+ ctx.pendingTimerDueAt = null;
57
+ ctx.waitSkipable = false;
58
+ if (ctx.get(SYS.waiting) !== "wait") return; // dispose 等场景防御
59
+ ctx.setSystem(SYS.waiting, "none");
60
+ ctx.liveCheckpointed = false; // live 已越过该检查点
61
+ ctx.run();
62
+ }, ms);
63
+ }
64
+
65
+ /**
66
+ * 清掉挂起的等待定时器(导航、销毁、读档与回溯前调用,避免旧计时器醒来改状态)。
67
+ *
68
+ * 暂停记账一并抹掉:这些入口都意味着「这一段等待作废」,留着剩余毫秒只会在
69
+ * 恢复时排出一个属于旧等待的定时器。
70
+ */
35
71
  export function clearTimer(ctx: OpContext): void {
36
72
  if (ctx.pendingTimer !== null) {
37
73
  ctx.timerPort.clear(ctx.pendingTimer);
38
74
  ctx.pendingTimer = null;
39
75
  }
76
+ ctx.pendingTimerDueAt = null;
77
+ ctx.pausedTimerRemainingMs = null;
78
+ }
79
+
80
+ /**
81
+ * 引擎是否处于暂停态:`SYS.paused` 的唯一读口。
82
+ *
83
+ * 暂停标记只由 `pause()` / `resume()` 写;推进类命令据此 fail-closed,
84
+ * 别的模块不要自己 `get` 一次再判——判据分叉后「谁能推进」就会有两套答案。
85
+ */
86
+ export function pausedSession(ctx: OpContext): boolean {
87
+ return ctx.get(SYS.paused) === true;
40
88
  }
41
89
 
42
90
  /**
@@ -79,6 +79,12 @@ export const BUILTIN_OP_NAMES: ReadonlySet<string> = new Set([
79
79
  "stop_ambient",
80
80
  "voice",
81
81
  "stop_voice",
82
+ "stop_se",
83
+ "audio_play",
84
+ "audio_stop",
85
+ "audio_volume",
86
+ "audio_pause",
87
+ "audio_resume",
82
88
  "video",
83
89
  "seek_video",
84
90
  "pause_video",
@@ -100,6 +106,7 @@ export const BUILTIN_OP_NAMES: ReadonlySet<string> = new Set([
100
106
  "transition",
101
107
  "shake",
102
108
  "text_typewriter",
109
+ "mount",
103
110
  ]);
104
111
 
105
112
  /** op 名须匹配扩展短 id 形态(小写字母开头、1..32 位),保证注册表键空间可控 */