@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
@@ -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 位),保证注册表键空间可控 */
@@ -11,6 +11,7 @@ import {
11
11
  type StoryCommand,
12
12
  } from "../../contracts/index.ts";
13
13
  import { interpolateText } from "../expr/index.ts";
14
+ import { armWaitTimer } from "../internal/index.ts";
14
15
  import type { Frame, OpContext } from "../internal/index.ts";
15
16
 
16
17
  /** say 的已知负载字段;未知字段 fail-closed */
@@ -232,17 +233,7 @@ export function execWait(
232
233
  ctx.liveCheckpointed = true;
233
234
  ctx.autoSaveAtCheckpoint(); // wait 等待画面建立 = auto_save 消费点
234
235
  frame.index += 1;
235
- ctx.pendingTimer = ctx.timerPort.set(
236
- () => {
237
- ctx.pendingTimer = null;
238
- ctx.waitSkipable = false;
239
- if (ctx.get(SYS.waiting) !== "wait") return; // dispose 等场景防御
240
- ctx.setSystem(SYS.waiting, "none");
241
- ctx.liveCheckpointed = false; // live 已越过该检查点
242
- ctx.run();
243
- },
244
- Math.max(0, (cmd.seconds as number) * 1000),
245
- );
236
+ armWaitTimer(ctx, Math.max(0, (cmd.seconds as number) * 1000));
246
237
  }
247
238
 
248
239
  /** NVL:进入/清屏/退出累积层;累积文本进核心状态(回溯/存档自动一致) */
@@ -21,7 +21,7 @@ import {
21
21
  type LoopState,
22
22
  type OpContext,
23
23
  } from "../internal/index.ts";
24
- import { findJsonValueError } from "../stateContract.ts";
24
+ import { findJsonValueError } from "../state/stateContract.ts";
25
25
 
26
26
  /** if/elif/else:条件执行期求值;分支体 = 块帧 + 块级作用域 */
27
27
  export function execIf(
@@ -39,7 +39,16 @@ export {
39
39
  execRandom,
40
40
  execUndef,
41
41
  } from "./variables.ts";
42
- export { execAudio, execNotify, execVideo } from "./media.ts";
42
+ export {
43
+ execAudio,
44
+ execAudioInstance,
45
+ execAudioPause,
46
+ execAudioResume,
47
+ execNotify,
48
+ execVideo,
49
+ pauseAudioInstances,
50
+ resumeAudioInstances,
51
+ } from "./media/index.ts";
43
52
  export {
44
53
  execAnimate,
45
54
  execElementVisual,
@@ -54,6 +63,7 @@ export {
54
63
  execSaveDeleteOp,
55
64
  execSaveOp,
56
65
  } from "./save.ts";
66
+ export { execMount } from "./ui.ts";
57
67
  export {
58
68
  abortInteraction,
59
69
  execInput,
@@ -0,0 +1,230 @@
1
+ /**
2
+ * 音频通道族:四通道(bgm / se / ambient / voice)的起播、停止与可恢复暂停。
3
+ *
4
+ * 核心只写通道状态,播放交给宿主适配器:状态回答「该不该出声、放什么」,
5
+ * 位置留在播放器里。`se` 是一次性触发(带单调 seq),常驻通道写状态对象
6
+ * (随快照 / 存档 / 回溯随行)。
7
+ */
8
+ import {
9
+ SYS,
10
+ isAudioChannel,
11
+ type AudioChannel,
12
+ type AudioChannelState,
13
+ type StoryCommand,
14
+ } from "../../../contracts/index.ts";
15
+ import type { OpContext } from "../../internal/index.ts";
16
+ import {
17
+ AUDIO_CHANNEL_KEY,
18
+ AUDIO_FIELDS,
19
+ AUDIO_STOP_KEY,
20
+ audioFade,
21
+ audioVolume,
22
+ isPlayState,
23
+ readPausedPlayStates,
24
+ } from "./shared.ts";
25
+
26
+ /**
27
+ * 四音频通道:核心只写状态,播放由 UI 适配器落地。
28
+ * - bgm/ambient/voice 为常驻通道(写状态对象,随快照/存档随行);se 为一次性触发(单调 seq)
29
+ * - 同资源重写保留播放位置:重放不打断当前曲目(回滚 seek / 读档续播)
30
+ * - stop_* 写 stop 形态(带淡出参数);未知字段/非法负载 fail-closed
31
+ */
32
+ export function execAudio(ctx: OpContext, cmd: StoryCommand): boolean {
33
+ const unknown = Object.keys(cmd).filter(
34
+ (k) => !(AUDIO_FIELDS[cmd.op]?.has(k) ?? false),
35
+ );
36
+ if (unknown.length > 0) {
37
+ ctx.fail(
38
+ `${cmd.op}-unknown-field`,
39
+ `${cmd.op} 未知负载字段:${unknown.join(", ")}`,
40
+ );
41
+ return false;
42
+ }
43
+ const fade = audioFade(ctx, cmd.fade, cmd.op);
44
+ if (fade === null) return false;
45
+ if (cmd.op === "stop_se") {
46
+ // 音效是「一次性触发 + 可停止」:停止也要带单调序号,
47
+ // 否则连续两次 stop_se 在渲染层看来是「状态没变」,第二次停不掉新触发的音效
48
+ ctx.mediaSeq += 1;
49
+ ctx.setSystem(SYS.audioSe, {
50
+ kind: "stop",
51
+ fadeMs: fade,
52
+ seq: ctx.mediaSeq,
53
+ } satisfies AudioChannelState);
54
+ return true;
55
+ }
56
+ if (
57
+ cmd.op === "stop_bgm" ||
58
+ cmd.op === "stop_ambient" ||
59
+ cmd.op === "stop_voice"
60
+ ) {
61
+ // 清通道为停止形态(淡出参数随状态走,不被丢弃)
62
+ const stopKey = AUDIO_STOP_KEY[cmd.op]!;
63
+ ctx.setSystem(stopKey, {
64
+ kind: "stop",
65
+ fadeMs: fade,
66
+ } satisfies AudioChannelState);
67
+ // 停止背景乐:播放位置归零(帧级键静默写)
68
+ if (cmd.op === "stop_bgm") ctx.state.set(SYS.bgmPosition, 0);
69
+ return true;
70
+ }
71
+ if (typeof cmd.resource !== "string" || cmd.resource === "") {
72
+ ctx.fail(
73
+ `${cmd.op}-invalid-resource`,
74
+ `${cmd.op} 需要非空 resource 字符串`,
75
+ );
76
+ return false;
77
+ }
78
+ // 走到这里 op 只剩四个起播命令(stop_* 已在前面的分支返回)⇒ 通道就是 op 本身
79
+ const channel = cmd.op as AudioChannel;
80
+ const volume = audioVolume(ctx, cmd.volume, channel, channel);
81
+ if (volume === null) return false;
82
+ if (cmd.op === "se") {
83
+ ctx.mediaSeq += 1;
84
+ ctx.setSystem(SYS.audioSe, {
85
+ kind: "play",
86
+ resource: cmd.resource,
87
+ volume,
88
+ loop: false,
89
+ fadeMs: 0,
90
+ seq: ctx.mediaSeq,
91
+ } satisfies AudioChannelState);
92
+ return true;
93
+ }
94
+ const channelKey = AUDIO_CHANNEL_KEY[cmd.op]!;
95
+ const previous = ctx.get(channelKey) as AudioChannelState | null | undefined;
96
+ const restart = cmd.restart === true;
97
+ if (cmd.op === "bgm") {
98
+ // 首播/换曲/显式重播归零;同曲静默续播(回滚 seek / 读档续播)。
99
+ // 帧级键静默写(高频键不进事件流),UI 经通道事件重读位置。
100
+ const sameTrack =
101
+ previous?.kind === "play" && previous.resource === cmd.resource;
102
+ if (!sameTrack || restart) ctx.state.set(SYS.bgmPosition, 0);
103
+ }
104
+ const state: AudioChannelState = {
105
+ kind: "play",
106
+ resource: cmd.resource,
107
+ volume,
108
+ loop: cmd.op === "voice" ? false : cmd.loop !== false,
109
+ fadeMs: fade,
110
+ };
111
+ if (cmd.op === "voice") state.autoStop = cmd.auto_stop !== false;
112
+ if (restart) {
113
+ // 单调序号:同曲连续两次 restart 也是两次独立事件(渲染层据此重播)
114
+ ctx.mediaSeq += 1;
115
+ state.restart = true;
116
+ state.seq = ctx.mediaSeq;
117
+ }
118
+ ctx.setSystem(channelKey, state);
119
+ return true;
120
+ }
121
+
122
+ /** 可暂停的常驻通道:`se` 是一次性触发,暂停它没有可恢复的东西 */
123
+ const PAUSABLE_CHANNELS = ["bgm", "ambient", "voice"] as const;
124
+ type PausableChannel = (typeof PAUSABLE_CHANNELS)[number];
125
+
126
+ /**
127
+ * 暂停目标通道:不传 `channel` = 全部常驻通道(`se` 除外)。
128
+ * 显式传 `se` 返回空集——一次性触发没有「暂停后从原位置继续」这回事,
129
+ * 静默不动作而不是报错(作者写了也不算坏数据)。
130
+ */
131
+ function pauseTargets(
132
+ ctx: OpContext,
133
+ cmd: StoryCommand,
134
+ ): readonly PausableChannel[] | null {
135
+ if (cmd.channel === undefined) return PAUSABLE_CHANNELS;
136
+ const raw = cmd.channel;
137
+ if (!isAudioChannel(raw)) {
138
+ ctx.fail(
139
+ `${cmd.op}-invalid-channel`,
140
+ `${cmd.op} 的 channel 不合法:${JSON.stringify(raw)}(须为 bgm / se / ambient / voice 之一)`,
141
+ );
142
+ return null;
143
+ }
144
+ if (raw === "se") return [];
145
+ return [raw];
146
+ }
147
+
148
+ /**
149
+ * `audio_pause`:把常驻通道从 play 改成 pause,播放器实例与位置留给端口。
150
+ *
151
+ * 三条纪律:
152
+ * - 只暂停 play 态——`stop` / 从未播放的通道不写,否则「从未触碰」会变成「暂停」,
153
+ * 恢复时无实例可续,stop 的淡出参数也跟着丢;
154
+ * - 已在 pause 态不重复写也不递增序号(幂等:连按两次暂停只有第一次是事件);
155
+ * - 暂停前的 play 态记进 `SYS.audioPaused`——`pause` 态本身不带资源,
156
+ * `audio_resume` 靠这份记账写回「从哪继续」(随快照 / 存档随行,读档后仍能恢复)。
157
+ */
158
+ export function execAudioPause(ctx: OpContext, cmd: StoryCommand): boolean {
159
+ const unknown = Object.keys(cmd).filter(
160
+ (k) => !(AUDIO_FIELDS[cmd.op]?.has(k) ?? false),
161
+ );
162
+ if (unknown.length > 0) {
163
+ ctx.fail(
164
+ `${cmd.op}-unknown-field`,
165
+ `${cmd.op} 未知负载字段:${unknown.join(", ")}`,
166
+ );
167
+ return false;
168
+ }
169
+ const channels = pauseTargets(ctx, cmd);
170
+ if (channels === null) return false;
171
+ for (const channel of channels) {
172
+ const key = AUDIO_CHANNEL_KEY[channel]!;
173
+ const previous = ctx.get(key);
174
+ if (!isPlayState(previous)) continue;
175
+ ctx.mediaSeq += 1;
176
+ ctx.setSystem(key, {
177
+ kind: "pause",
178
+ seq: ctx.mediaSeq,
179
+ } satisfies AudioChannelState);
180
+ const paused: Record<string, AudioChannelState> = {
181
+ ...readPausedPlayStates(ctx),
182
+ };
183
+ paused[channel] = previous;
184
+ ctx.setSystem(SYS.audioPaused, paused);
185
+ }
186
+ return true;
187
+ }
188
+
189
+ /**
190
+ * `audio_resume`:把 pause 态的通道写回暂停前的 play 态。
191
+ *
192
+ * 写回的是**同一个 play 态**(资源 / 音量 / 循环 / 淡出参数原样),所以渲染层
193
+ * 看到的是「pause → 同资源 play」,走 `resume` 而不是重新 `play`——从暂停位置续播,
194
+ * 不从头播。`play` / `stop` 态的通道不动作(幂等)。
195
+ *
196
+ * 没有记账可续(例如手工改过档)时落成 `stop`:留一个永远无法恢复的 pause 态
197
+ * 比停掉更糟——那条通道会一直「该出声但不出声」。
198
+ */
199
+ export function execAudioResume(ctx: OpContext, cmd: StoryCommand): boolean {
200
+ const unknown = Object.keys(cmd).filter(
201
+ (k) => !(AUDIO_FIELDS[cmd.op]?.has(k) ?? false),
202
+ );
203
+ if (unknown.length > 0) {
204
+ ctx.fail(
205
+ `${cmd.op}-unknown-field`,
206
+ `${cmd.op} 未知负载字段:${unknown.join(", ")}`,
207
+ );
208
+ return false;
209
+ }
210
+ const channels = pauseTargets(ctx, cmd);
211
+ if (channels === null) return false;
212
+ for (const channel of channels) {
213
+ const key = AUDIO_CHANNEL_KEY[channel]!;
214
+ const current = ctx.get(key);
215
+ if (current === null || typeof current !== "object") continue;
216
+ if ((current as { kind?: unknown }).kind !== "pause") continue;
217
+ const paused = readPausedPlayStates(ctx);
218
+ const resumed = paused[channel];
219
+ ctx.setSystem(
220
+ key,
221
+ resumed ?? ({ kind: "stop", fadeMs: 0 } satisfies AudioChannelState),
222
+ );
223
+ if (resumed !== undefined) {
224
+ const next: Record<string, AudioChannelState> = { ...paused };
225
+ delete next[channel];
226
+ ctx.setSystem(SYS.audioPaused, next);
227
+ }
228
+ }
229
+ return true;
230
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 媒体命令的执行:音频通道、视频命令流、出站通知。
3
+ *
4
+ * 这一族的共同点是「核心只写状态,播放交给宿主」——音频写四通道的通道对象,
5
+ * 视频写单调递增的命令流,通知只发出站事件。渲染器按状态与事件执行,
6
+ * 核心不持有播放器,也不关心播放是否成功。
7
+ *
8
+ * 按「音频通道 / 视频 / 音频实例 / 通知」分文件,域内共用部分在 `shared.ts`;
9
+ * 这里只做转发,不放任何实现。
10
+ */
11
+ export { execAudio, execAudioPause, execAudioResume } from "./audio.ts";
12
+ export { execVideo } from "./video.ts";
13
+ export {
14
+ execAudioInstance,
15
+ pauseAudioInstances,
16
+ resumeAudioInstances,
17
+ } from "./instances.ts";
18
+ export { execNotify } from "./notify.ts";