@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingfan-engine/core",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "灵泛叙事引擎核心:框架无关的叙事语义(格式/执行/回溯/作用域/存档编排)",
6
6
  "license": "Apache-2.0",
@@ -4,6 +4,7 @@
4
4
  * 这些形状被跨包消费(编辑器扩展出口、UI 覆盖层、宿主组合根、生成的故事代码),
5
5
  * 且本身不依赖引擎实现,故与其它契约同住契约层;引擎实现只按契约读取它们。
6
6
  */
7
+ import type { AudioConfig } from "./shell.ts";
7
8
  import type { OpExtension } from "./extension.ts";
8
9
  import type { MinigameRegistration } from "./minigame.ts";
9
10
  import type { SaveDataV1, SaveMode, SavePort } from "./save.ts";
@@ -21,6 +22,14 @@ export interface EngineOptions {
21
22
  savePort?: SavePort;
22
23
  /** 存档模式(缺省 machine-bound;与 Rust 层同缺省) */
23
24
  saveMode?: SaveMode;
25
+ /**
26
+ * 工程默认音量(`project.json` 的 `shell.audio`,宿主经 `resolveAudioConfig` 解析后注入)。
27
+ *
28
+ * 它是音量的**第二级**:命令没写 `volume=` 时取这里,再没有才是内建 1。
29
+ * 缺省 = 四通道皆 1(与「不指定 = 满音量」的既有行为逐字节一致)。
30
+ * 玩家偏好不在这一层——偏好是渲染层最外层的乘算。
31
+ */
32
+ audioConfig?: AudioConfig;
24
33
  /** 译文供给端口(setLanguage 按需加载译文;缺省 = 原文直出) */
25
34
  i18nPort?: I18nPort;
26
35
  /**
@@ -11,6 +11,7 @@ export * from "./shell.ts";
11
11
  export * from "./element.ts";
12
12
  export * from "./extension.ts";
13
13
  export * from "./game.ts";
14
+ export * from "./param-direction.ts";
14
15
  export * from "./guard.ts";
15
16
  export * from "./messages.ts";
16
17
  export * from "./text.ts";
@@ -4,13 +4,42 @@
4
4
  *
5
5
  * 端口(播放器要实现的方法集合)见同目录 `ports.ts`。
6
6
  */
7
+ import { SHORT_ID_PATTERN } from "../identifiers.ts";
7
8
 
8
9
  /** 四通道:bgm 循环 / se 一次性 / ambient 独立循环层 / voice 互斥 */
9
10
  export type AudioChannel = "bgm" | "se" | "ambient" | "voice";
10
11
 
11
12
  /**
12
- * 通道状态(SSOT 系统键值):play = 播放或续播,stop = 停止(fadeMs 淡出);null = 从未触碰。
13
- * `stop` 独立成形态,避免用哨兵资源名承载「停止」而丢失淡出参数。
13
+ * 通道全集(顺序即偏好表的书写顺序)。
14
+ *
15
+ * 唯一声明处:解析工程默认音量、判通道合法性、渲染层逐通道取状态都读它,
16
+ * 各处不得自建同名数组——多一份就多一个「新增通道时漏改」的位置。
17
+ */
18
+ export const AUDIO_CHANNELS: readonly AudioChannel[] = [
19
+ "bgm",
20
+ "se",
21
+ "ambient",
22
+ "voice",
23
+ ];
24
+
25
+ /**
26
+ * 常驻通道:跨命令持续存在的三条。
27
+ *
28
+ * `se` 是一次性音效,播完即弃——它没有「暂停后从原位置续播」的语义,因此不在其中。
29
+ * 「不指定通道就作用于全部常驻通道」这类批量语义(引擎的 `audio_pause` 不带通道、
30
+ * 宿主控制面的整局暂停与恢复)都读这张表,不各自写字面量。
31
+ */
32
+ export const PERSISTENT_AUDIO_CHANNELS: readonly AudioChannel[] = [
33
+ "bgm",
34
+ "ambient",
35
+ "voice",
36
+ ];
37
+
38
+ /**
39
+ * 通道状态(SSOT 系统键值):play = 播放或续播,pause = 暂停(保留播放器与位置、不出声),
40
+ * stop = 停止(fadeMs 淡出);null = 从未触碰。
41
+ * `stop` 独立成形态,避免用哨兵资源名承载「停止」而丢失淡出参数;
42
+ * `pause` 同样独立成形态——暂停不是「音量归零」,恢复时必须从原位置续播而不是从头播。
14
43
  */
15
44
  export type AudioChannelState =
16
45
  | {
@@ -31,7 +60,60 @@ export type AudioChannelState =
31
60
  */
32
61
  seq?: number;
33
62
  }
34
- | { kind: "stop"; fadeMs: number };
63
+ | {
64
+ /**
65
+ * 暂停:不带资源,与视频命令的 `{ kind: "pause"; seq }` 同构。
66
+ * 恢复时由渲染层回到暂停前的位置续播,故这里不记位置(位置在播放器里)。
67
+ */
68
+ kind: "pause";
69
+ seq?: number;
70
+ }
71
+ | {
72
+ kind: "stop";
73
+ fadeMs: number;
74
+ /**
75
+ * 停止请求的单调序号(仅 `se` 写入,不进快照):
76
+ * `se` 是「一次性触发 + 可停止」的通道,连续两次 `stop_se` 也必须是两次独立事件,
77
+ * 否则渲染层无法分辨「又停了一次」与「状态没变」。
78
+ */
79
+ seq?: number;
80
+ };
81
+
82
+ /**
83
+ * 音频实例的 id 形态:与扩展 / 玩法系统的短 id 同源(同一份 `SHORT_ID_PATTERN`)。
84
+ *
85
+ * 作者要在故事里按名字点名实例(`audio_play inst="rain"`),所以 id 也是契约的一部分;
86
+ * 另写一条正则会立刻与短 id 分叉,故这里只是**别名**,改一处即三处生效。
87
+ */
88
+ export const AUDIO_INSTANCE_ID_PATTERN = SHORT_ID_PATTERN;
89
+
90
+ /**
91
+ * 同时存活的音频实例上限。
92
+ *
93
+ * 整张表是一个状态值 ⇒ 每加一个实例都进快照、进存档。上限不是为了省内存
94
+ * (真实瓶颈在解码器那边),而是为了让「存档体积」这件事有界:没有上限时
95
+ * 一个循环里的 `audio_play` 能把存档撑到无法写盘,而那种错发现得太晚。
96
+ */
97
+ export const MAX_AUDIO_INSTANCES = 32;
98
+
99
+ /**
100
+ * 单个音频实例的状态:**归属哪条通道** + 与通道键同构的播放状态。
101
+ *
102
+ * 为什么要记 `channel`:音量偏好是按通道分的(BGM 0.8 / 音效 0.6 …),
103
+ * 不记归属就没法把偏好乘到实例上,作者也只能靠命名猜。
104
+ */
105
+ export interface AudioInstanceState {
106
+ readonly channel: AudioChannel;
107
+ readonly state: AudioChannelState;
108
+ }
109
+
110
+ /**
111
+ * 音频实例表(SSOT 系统键 `__audio_instances` 的值):实例 id → 状态。
112
+ *
113
+ * **写时复制**:整表是一个值,改写时必须新建对象而不是原地改字段——快照与存档
114
+ * 持有的是同一个引用的历史版本,原地改会让回溯拿到被污染的值。
115
+ */
116
+ export type AudioInstanceTable = Readonly<Record<string, AudioInstanceState>>;
35
117
 
36
118
  /** 音频播放参数(端口入参,核心不碰解码器) */
37
119
  export interface AudioPlayOptions {
@@ -12,9 +12,52 @@ import type { AudioChannel, AudioPlayOptions } from "./entities.ts";
12
12
  export interface AudioPort {
13
13
  play(channel: AudioChannel, url: string, options: AudioPlayOptions): void;
14
14
  stop(channel: AudioChannel, fadeMs?: number): void;
15
+ /**
16
+ * 暂停通道:保留播放器实例与播放位置,只是不出声。
17
+ * 通道已停止或从未播放时空操作(不建实例、不改状态)。
18
+ */
19
+ pause(channel: AudioChannel): void;
20
+ /**
21
+ * 从暂停位置继续播放;未暂停的空操作。
22
+ * 与 `play` 的区别:不从起点重播、不重建实例。
23
+ */
24
+ resume(channel: AudioChannel): void;
15
25
  /** 当前播放位置(秒);未播放返回 0(帧级回写用) */
16
26
  position(channel: AudioChannel): number;
17
27
  dispose(): void;
28
+
29
+ // —— 实例维度(可选能力)——
30
+ // 四通道是叙事语义,实例是作者的编排单元:两者并存,不是替换。
31
+ // 三个方法都是**可选**的:老适配器不实现即退化为「实例不发声」,
32
+ // 调用方用 `?.` 容错并上报一次诊断——不能因为端口没跟上就整段卡住。
33
+
34
+ /**
35
+ * 起播一个实例(`instanceId` 由作者在故事里命名)。
36
+ *
37
+ * 与 `play` 的三点差别:入参第一维不是通道(归属通道在状态里,供偏好分档)、
38
+ * 同一 id 重复起播是「更新」而非叠放、不同 id 各自独立可叠放。
39
+ */
40
+ playInstance?(
41
+ instanceId: string,
42
+ url: string,
43
+ options: AudioPlayOptions,
44
+ ): void;
45
+ /** 停掉一个实例;`fadeMs` 为淡出毫秒 */
46
+ stopInstance?(instanceId: string, fadeMs?: number): void;
47
+ /**
48
+ * 运行期改一个实例的音量(0..1),**不重播**。
49
+ *
50
+ * 专为「拖滑块实时生效」而设:走 `playInstance` 重发也能改音量,
51
+ * 但那会重设位置、打断正在放的淡入。未实现的适配器只能靠下一次起播生效。
52
+ */
53
+ setVolumeInstance?(instanceId: string, volume: number): void;
54
+ /**
55
+ * 暂停一个实例:保留播放器与位置,不出声。
56
+ * 可选能力:未实现者由调用方按整通道 `pause` 降级。
57
+ */
58
+ pauseInstance?(instanceId: string): void;
59
+ /** 从暂停位置继续一个实例;未暂停的空操作。可选能力,同 `pauseInstance`。 */
60
+ resumeInstance?(instanceId: string): void;
18
61
  }
19
62
 
20
63
  // —— 视频(单通道命令流):新 video 替换当前;Desktop/Mobile 同契约 ——
@@ -41,4 +84,9 @@ export interface VideoPort {
41
84
  * 可选能力:未实现者保持构造期 `zIndex` 行为(调用方用 `?.` 容错)。
42
85
  */
43
86
  setZIndex?(z: number): void;
87
+ /**
88
+ * 运行期改音量(0..1):偏好滑块拖动时宿主用它,**不重播**——重播会打断过场。
89
+ * 可选能力:未实现者只能靠下一次 `play` 生效(调用方用 `?.` 容错)。
90
+ */
91
+ setVolume?(volume: number): void;
44
92
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * 参数方向档位:谁能写某个状态键。
3
+ *
4
+ * **为什么需要这张表**:状态键一律是「一个键 + 一个值」,看不出「宿主能不能改它」。
5
+ * 结果是「宿主可写」靠口头约定维持——新加一个键时没人知道该不该给写权限,
6
+ * 而给错了不是小事:`__waiting` 若能写,宿主就能伪造等待态绕过等待状态机;
7
+ * `__audio_*` 若能直写,就绕过了音量偏好乘算。
8
+ *
9
+ * **本表只声明,不实现**。写路径仍归各域既有接缝(`GameStateWriter` / `setVolume` / 命令面);
10
+ * 物理闸仍是 `RESERVED_STATE_KEYS`(保留键一律拒直写)。表的作用是让「谁该能写」
11
+ * 可查、可测、可守——测试据此断言「声明与能力一致」。
12
+ *
13
+ * **缺省只读**(fail-closed):表里没有的键一律 `readonly`。新键默认不给写权限,
14
+ * 要开放必须显式登记——与「白名单优于黑名单」同一条纪律。
15
+ */
16
+ import { SYS } from "./runtime/state-keys.ts";
17
+
18
+ /**
19
+ * 方向档位:
20
+ * - `readonly`:引擎独占。宿主读得到,写不生效(直写命中保留键即 fail-closed)
21
+ * - `write`:单向。宿主可写,引擎不主动改(如外部玩法系统状态)
22
+ * - `two-way`:双向。引擎写、宿主也可写,冲突按各域既有优先级解析
23
+ */
24
+ export type ParamDirection = "readonly" | "write" | "two-way";
25
+
26
+ /**
27
+ * 键 → 档位。
28
+ *
29
+ * 键可以是**精确系统键**(`__waiting`)、**命名空间前缀**(`game.` / `preferences.`)。
30
+ * 查表走 `runtime/paramDirection.ts` 的 `directionOf`:精确键优先,其次最长前缀,都不命中即 `readonly`。
31
+ *
32
+ * 登记纪律:声明为 `two-way` 的系统键**必须有一条专用接缝**(否则声明与实际能力脱节,
33
+ * 由测试 `param-direction` 锁住);新增 `SYS` 键时若忘登记,测试会报「未声明方向的系统键」。
34
+ *
35
+ * **查表函数不在本文件**:契约层只放类型与数据(本层出现函数声明即违约,
36
+ * 由 `tests/engine/runtime/scene-type.test.ts` 锁住),实现落在 `runtime/paramDirection.ts`。
37
+ */
38
+ export const PARAM_DIRECTION: Readonly<Record<string, ParamDirection>> = {
39
+ // —— 只读:宿主读得到、写不生效(等待状态机与舞台事实归引擎)——
40
+ [SYS.waiting]: "readonly",
41
+ [SYS.currentSceneColumn]: "readonly",
42
+ [SYS.menuPrompt]: "readonly",
43
+ [SYS.menuOptions]: "readonly",
44
+ [SYS.menuTargets]: "readonly",
45
+ [SYS.menuSelected]: "readonly",
46
+ [SYS.inputPrompt]: "readonly",
47
+ [SYS.inputOptions]: "readonly",
48
+ [SYS.currentDialogText]: "readonly",
49
+ [SYS.currentDialogSpeaker]: "readonly",
50
+ [SYS.currentDialogColor]: "readonly",
51
+ [SYS.dialogFont]: "readonly",
52
+ [SYS.dialogTemplate]: "readonly",
53
+ [SYS.dialogComplete]: "readonly",
54
+ [SYS.dialogClickable]: "readonly",
55
+ [SYS.dialogNoskip]: "readonly",
56
+ [SYS.dialogVisible]: "readonly",
57
+ [SYS.elements]: "readonly",
58
+ [SYS.animations]: "readonly",
59
+ [SYS.transition]: "readonly",
60
+ [SYS.shake]: "readonly",
61
+ [SYS.video]: "readonly",
62
+ [SYS.videoSkipable]: "readonly",
63
+ [SYS.nvlMode]: "readonly",
64
+ [SYS.nvlBuffer]: "readonly",
65
+ [SYS.rollbackActive]: "readonly",
66
+ [SYS.currentLanguage]: "readonly",
67
+ [SYS.autoSave]: "readonly",
68
+ [SYS.menuReturn]: "readonly",
69
+ /** 引擎级暂停标记:只由命令面 `pause()` / `resume()` 写,宿主只读 */
70
+ [SYS.paused]: "readonly",
71
+ [SYS.minigame]: "readonly",
72
+ [SYS.interaction]: "readonly",
73
+ [SYS.typewriter]: "readonly",
74
+ /** 实例级 z:命令写、宿主按它解析后下发(宿主不改这个值本身) */
75
+ [SYS.dialogueZ]: "readonly",
76
+ [SYS.choicesZ]: "readonly",
77
+ [SYS.notificationsZ]: "readonly",
78
+ [SYS.minigameZ]: "readonly",
79
+ [SYS.interactionZ]: "readonly",
80
+ [SYS.videoZ]: "readonly",
81
+
82
+ // —— 单向:宿主写,引擎不主动改 ——
83
+ /** 外部玩法系统 / 扩展的状态命名空间(写入经 `GameStateWriter` / `ExtensionContext`) */
84
+ "game.": "write",
85
+ "ext.": "write",
86
+
87
+ // —— 双向:引擎写、宿主可写(各有专用接缝)——
88
+ /** 玩家偏好:引擎读、宿主写(`PlayerPreferences.setVolume` 等),变化触发渲染层重规划 */
89
+ "preferences.": "two-way",
90
+ /** 音频四通道:命令写状态、偏好层再乘算;专用接缝 = 命令面 + `PlayerPreferences` */
91
+ [SYS.audioBgm]: "two-way",
92
+ [SYS.audioSe]: "two-way",
93
+ [SYS.audioAmbient]: "two-way",
94
+ [SYS.audioVoice]: "two-way",
95
+ /** 音频实例表:命令写、宿主可按 id 改音量;专用接缝 = 实例命令面 */
96
+ [SYS.audioInstances]: "two-way",
97
+ /** 背景乐播放位置:引擎回写、宿主可定位(读档后对齐) */
98
+ [SYS.bgmPosition]: "two-way",
99
+ /** 暂停前播放态记账:命令写、`audio_resume` 读回;专用接缝 = `audio_pause` / `audio_resume` */
100
+ [SYS.audioPaused]: "two-way",
101
+ /** 挂载 UI 表:命令写显隐与入参、宿主读后挂载/卸载组件;专用接缝 = `mount` op */
102
+ [SYS.mounts]: "two-way",
103
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 列摘要:宿主看故事时能看到的那部分列信息。
3
+ *
4
+ * **为什么只给这三个字段**:宿主需要的是「有哪些列、各是什么类型、当前在哪列」——
5
+ * 用来列章节、判当前页、决定跳哪。列里的 `commands` / `elements` / `entry` 归引擎,
6
+ * 宿主拿到它们只会诱使自己绕过引擎去解释命令流(两套解释器必然漂移)。
7
+ *
8
+ * **只读**:字段全部 `readonly`,且引擎给出的实例是冻结的——摘要是引擎状态的镜像,
9
+ * 不是可写副本。要改列内容走故事文件与热重载,不从这里改。
10
+ *
11
+ * 与 `StoryColumn` 的关系:这是它的**投影**,不是精简版故事树。`type` 在这里是
12
+ * 归一后的值(缺省 `game`),宿主因此不必自己处理「没写 type 算哪种」。
13
+ */
14
+ import type { SceneType } from "../story/index.ts";
15
+
16
+ /** 单列的只读摘要:id、归一后的运行语义类型、内容形态 */
17
+ export interface ColumnSummary {
18
+ /** 列 id(故事内唯一;跳转与当前列判定都用它) */
19
+ readonly id: string;
20
+ /** 运行语义类型(**已归一**:`type` 缺省或非法时取 `game`) */
21
+ readonly type: SceneType;
22
+ /** 内容形态:`scene` = 空间层(元素 + 入口命令)/ `flow` = 纯流程 */
23
+ readonly kind: "scene" | "flow";
24
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 宿主控制面:宿主与游戏内挂载 UI 操作引擎的**收窄**通道。
3
+ *
4
+ * 它不是 `StoryEngine` 的别名——引擎有几十个公开方法,其中大半是「引擎内部推进
5
+ * 时顺手通知」的接缝(`animationFinished` / `choose` / `rollbackTo` …),跨形态也
6
+ * 带不进挂载层;这里只留「菜单需要的那几件事」:暂停与恢复、换栏、存读、收摊。
7
+ *
8
+ * 与 `props` 是两条不同的通道,别混:
9
+ * - `props` 来自故事命令的第三参数(`mount(hub, true, {scene: "forest"})`),落在
10
+ * `SYS.mounts` 里,随快照存档与回溯,因此**必须 JSON 安全**——函数进不去;
11
+ * - `controls` 来自组合根,不进 SSOT、不进快照、运行期注入,**可以带函数**。
12
+ *
13
+ * 组件两样都拿得到:值走 `props`,操作走 `controls`。
14
+ */
15
+ import type { AudioChannel } from "../media/entities.ts";
16
+ import type { SaveOptions } from "../save.ts";
17
+
18
+ /** 宿主控制面:菜单与挂载 UI 共用的操作集合(两条路实现零差异) */
19
+ export interface HostControls {
20
+ /** 暂停整局:推进、等待计时、音频与视频同停;幂等 */
21
+ pause(): void;
22
+ /** 恢复整局:回到暂停前的位置继续;幂等 */
23
+ resume(): void;
24
+ /** 当前是否处于暂停态 */
25
+ paused(): boolean;
26
+ /**
27
+ * 暂停媒体;不传通道即全部常驻通道 + 视频。
28
+ *
29
+ * `se` 是一次性音效,没有「暂停后从原位置续播」的语义——引擎侧同样不动作,
30
+ * 这里显式跳过而不是交给端口各自决定。
31
+ */
32
+ pauseMedia(channel?: AudioChannel): void;
33
+ /** 恢复媒体;参数口径同 {@link HostControls.pauseMedia} */
34
+ resumeMedia(channel?: AudioChannel): void;
35
+ /** 跳到某一栏(与 `menu` 的选项同义:栏 id) */
36
+ navigate(columnId: string): void;
37
+ /**
38
+ * 存档到某个槽位。
39
+ *
40
+ * `options.title` 是玩家给这一档起的名字,写进 `SaveDataV1.title`,槽位列表据此
41
+ * 显示;不传就是无名档(列表只显示槽位号)。缩略图同理走 `options.screenshot`,
42
+ * 由宿主合成后交给引擎——截图策略归宿主,引擎只负责把它存进档里。
43
+ */
44
+ save(slot: string, options?: SaveOptions): void;
45
+ /** 从某个槽位读档 */
46
+ load(slot: string): void;
47
+ /** 拆掉宿主(等同宿主句柄的拆除;由持有者负责只调一次) */
48
+ dispose(): void;
49
+ }
@@ -18,6 +18,27 @@ export type WaitingState =
18
18
  /** NVL 模式(`__nvl_mode` 取值):累积层开关与清屏动作 */
19
19
  export type NvlMode = "none" | "active" | "clear" | "exit";
20
20
 
21
+ /**
22
+ * 一条挂载记录:某个 UI 组件该不该出现在渲染树上、带什么入参。
23
+ *
24
+ * `visible: false` 的语义是**从渲染树移除**(卸载组件),不是 `display: none`——
25
+ * 隐藏的组件不该继续跑生命周期、计时器与副作用。
26
+ */
27
+ export interface MountEntry {
28
+ /** 是否挂在渲染树上 */
29
+ visible: boolean;
30
+ /** 传给组件的入参(JSON 安全;缺省 = 空对象) */
31
+ props: Record<string, unknown>;
32
+ }
33
+
34
+ /**
35
+ * 挂载表(`__mounts` 取值):UI 名 → 挂载记录。
36
+ *
37
+ * 核心只认识名字与显隐,不认识组件内部;整表一个值 ⇒ 快照 / 存档 / 回溯零特判随行。
38
+ * 写时整表新建(写时复制),原地改会与快照共享引用。
39
+ */
40
+ export type MountTable = Readonly<Record<string, MountEntry>>;
41
+
21
42
  /** 角色定义(character op 注册;say speaker 匹配自动套样式) */
22
43
  export interface CharacterDef {
23
44
  key: string;
@@ -5,4 +5,6 @@ export * from "./events.ts";
5
5
  export * from "./state-keys.ts";
6
6
  export * from "./entities.ts";
7
7
  export * from "./reader.ts";
8
+ export * from "./column-summary.ts";
8
9
  export * from "./timer.ts";
10
+ export * from "./controls.ts";
@@ -21,15 +21,28 @@ import type {
21
21
  VideoCommand,
22
22
  } from "../media/index.ts";
23
23
  import type { LayerZOverrides } from "../shell.ts";
24
+ import type { SceneType } from "../story/index.ts";
25
+ import type { ColumnSummary } from "./column-summary.ts";
24
26
 
25
27
  /**
26
28
  * 取值来源:任何能按键读状态的对象都满足。
27
29
  *
28
- * 只要求这一个方法是有意为之——引擎实例满足它,测试替身、只读快照、状态代理也满足,
30
+ * 只要求 `get` 是有意为之——引擎实例满足它,测试替身、只读快照、状态代理也满足,
29
31
  * 于是读口不必依赖 `StoryEngine` 的全部能力,凡是「能读」的地方都能复用同一份校验。
30
32
  */
31
33
  export interface StateSource {
32
34
  get(key: string): unknown;
35
+ /**
36
+ * 可选:列清单读数(引擎实例天然满足)。
37
+ *
38
+ * **为什么读口需要它**:`currentColumnType()` 要由「当前列 id」查「列类型」,
39
+ * 而状态里只存 id,不存类型——类型是故事定义的一部分。不提供它时读口退回缺省
40
+ * `game`,不会抛错:读口只承诺「不撒谎」,不承诺「总能知道」。
41
+ *
42
+ * 提供者必须给出**已归一**的 `type`(见 `ColumnSummary`),读口不再自己判一遍,
43
+ * 否则缺省口径就有两份。
44
+ */
45
+ columns?(): readonly ColumnSummary[];
33
46
  }
34
47
 
35
48
  /** 菜单选项:文本与目标列成对(数组顺序即呈现顺序) */
@@ -38,6 +51,17 @@ export interface MenuChoice {
38
51
  target: string;
39
52
  }
40
53
 
54
+ /**
55
+ * 输入候选:显示文本与要填进变量的值成对(数组顺序即呈现顺序)。
56
+ *
57
+ * 与 `MenuChoice` 分开定义:候选是「替玩家填一个值」,不是「跳到另一列」,
58
+ * 字段名不同是刻意的——把两者混用会在多语言环境下把译文写进变量。
59
+ */
60
+ export interface InputCandidate {
61
+ text: string;
62
+ value: string;
63
+ }
64
+
41
65
  /**
42
66
  * 类型化读口:按用途分组读状态。
43
67
  *
@@ -53,6 +77,8 @@ export interface StateReader {
53
77
  videoCommand(): VideoCommand | null;
54
78
  /** 菜单选项(文本与目标列成对;两键分属不同状态条目,任一畸形都按空处理) */
55
79
  menuChoices(): MenuChoice[];
80
+ /** 输入候选(显示文本与要填的值成对;缺省或形状不合 = 空数组,界面只渲染输入框) */
81
+ inputCandidates(): InputCandidate[];
56
82
  /** 当前说话人(缺省空串) */
57
83
  dialogSpeaker(): string;
58
84
  /** 当前正文(缺省空串) */
@@ -79,4 +105,18 @@ export interface StateReader {
79
105
  elements(): ElementInstance[];
80
106
  /** 实例级 z 覆盖:只含显式指定过的层(缺省 = 层默认) */
81
107
  instanceZOverrides(): LayerZOverrides;
108
+ /**
109
+ * 当前所在列 id(**缺省空串** = 尚未进入任何列,或状态被改坏)。
110
+ *
111
+ * 空串是「还没有位置」而不是某个真实列:跳转与高亮请按空串当「无当前位置」处理。
112
+ */
113
+ currentColumnId(): string;
114
+ /**
115
+ * 当前所在列的类型(**缺省 `game`**)。
116
+ *
117
+ * 与「可回溯判定」同源:启动期没有当前列时按 `game` 算,与引擎对启动期的既有口径一致;
118
+ * 当前列 id 在列清单里找不到(热重载后坐标失效等)同样按 `game`。
119
+ * 读口不知道列清单(`StateSource.columns` 未提供)时也只能给 `game`——它不猜。
120
+ */
121
+ currentColumnType(): SceneType;
82
122
  }
@@ -36,6 +36,14 @@ export const SYS = {
36
36
  menuTargets: "__menu_targets",
37
37
  menuSelected: "__menu_selected",
38
38
  inputPrompt: "__input_prompt",
39
+ /**
40
+ * 输入等待的**候选快捷值**(`input options=[…]` 写入):`{ text, value }[]`。
41
+ *
42
+ * 语义:玩家既可以点候选(把该项 `value` 直接当输入值提交),也可以自己打字。
43
+ * 与 `menuOptions` 分开存,因为两者字段不同(候选是「替玩家填值」不是「跳列」),
44
+ * 混用一个键会让读口无法判断该按哪种形状解析。缺省 = 无候选。
45
+ */
46
+ inputOptions: "__input_options",
39
47
  rollbackActive: "__rollback_active",
40
48
  /** I18N 当前语言(setLanguage 命令写入,空串 = 默认语言/原文直出) */
41
49
  currentLanguage: "__current_language",
@@ -50,10 +58,18 @@ export const SYS = {
50
58
  * **用途:在菜单里按存档,存的是「菜单前的游戏进度」**。值形如
51
59
  * `{ coord: {columnId, index}, waiting: string }`,缺省 = 不在菜单中。
52
60
  *
53
- * **不进快照的可见状态**:它是**引擎内部记账**,读取档不依赖它
54
- * —— 档里存的坐标已经是菜单前那个。
61
+ * **属于会话态**(见 `SESSION_STATE_KEYS`):进快照(回溯要用),
62
+ * **不进存档**——档里存的坐标已经是菜单前那个,带着它读档会让引擎
63
+ * 以为「仍在菜单里」。
55
64
  */
56
65
  menuReturn: "__menu_return",
66
+ /**
67
+ * 引擎级暂停标记(`pause()` 置真、`resume()` 清除)。
68
+ *
69
+ * **属于会话态**:进快照,**不进存档**——档里的进度就是暂停前那一刻,
70
+ * 带着它读档会让引擎一开局就停在暂停中(`advance` 直接 `pause-active`)。
71
+ */
72
+ paused: "__paused",
57
73
  /**
58
74
  * 实例级 z:单控件实例显式指定的层级(story 命令上的 `z`)。
59
75
  * 进 SSOT → 随快照 / 存档 / 回溯自动随行(重放到同一条命令即重新写入同一值)。
@@ -77,8 +93,31 @@ export const SYS = {
77
93
  audioSe: "__audio_se",
78
94
  audioAmbient: "__audio_ambient",
79
95
  audioVoice: "__audio_voice",
96
+ /**
97
+ * 音频实例表:`instanceId → { channel, state }`,与上面四个通道键**并存**。
98
+ *
99
+ * 四通道是叙事语义(这段是 BGM / 这句是语音),必须有默认值、随档、被偏好分档乘算;
100
+ * 实例是作者的编排需要(两段环境音交叉、三段音效各调各的),数量不定、名字作者起。
101
+ * 两者不是同一件事,所以实例另开一张表,不改四通道的任何形态。
102
+ *
103
+ * 整表是一个值 ⇒ 快照 / 存档 / 回溯零特判随行;**写时必须整表新建**(写时复制,
104
+ * 原地改会与快照共享引用,回溯后旧值被一起改掉)。
105
+ */
106
+ audioInstances: "__audio_instances",
80
107
  /** 帧级键:UI 每帧回写,不进事件流 */
81
108
  bgmPosition: "__bgm_position",
109
+ /**
110
+ * **暂停前的播放态记账**:`通道 → 被暂停的那个 play 态`。
111
+ *
112
+ * `pause` 态本身不带资源(资源与位置在端口的播放器实例里),而 `audio_resume`
113
+ * 要把通道写回「该出声」的形态就必须知道放的是什么——这份记账就是那个来源。
114
+ * 进 SSOT ⇒ 随快照 / 存档 / 回溯随行:读档到暂停期间仍能恢复,回溯到暂停前
115
+ * 则连记账一起回到未暂停。
116
+ *
117
+ * 只在 `audio_pause` 写入、`audio_resume` 消费并删除;通道被 `stop` / 重新
118
+ * `play` 覆盖时残留项无人读取(`audio_resume` 只认 `pause` 态的通道)。
119
+ */
120
+ audioPaused: "__audio_paused",
82
121
  // 视频命令流(单通道):状态入 SSOT → 快照随行
83
122
  video: "__video",
84
123
  /** video_skipable op 的持久开关(后续 video/cutscene 的缺省 skipable) */
@@ -95,6 +134,19 @@ export const SYS = {
95
134
  * 列切换清空(空间层属于列),随快照/存档/回溯自动随行(整体 state Map 快照)。
96
135
  */
97
136
  elements: "__elements",
137
+ /**
138
+ * 挂载到宿主渲染树上的 UI 组件表(`mount` op 写入):
139
+ * `Record<string, { visible: boolean; props: Record<string, unknown> }>`。
140
+ *
141
+ * 与 `elements` 不是同一件事:`elements` 是核心自己排布的舞台元素,带 animate /
142
+ * style / zindex 等表现字段,核心知道它们长什么样;`mounts` 只是「哪个 UI 组件该不该
143
+ * 出现在渲染树上、带什么入参」,核心不认识组件内部,也从不操作 DOM。
144
+ * `visible: false` 的语义是**从渲染树移除**(不是 `display: none`),宿主据此卸载组件。
145
+ *
146
+ * 整表是一个值 ⇒ 快照 / 存档 / 回溯零特判随行;**写时必须整表新建**(写时复制,
147
+ * 原地改会与快照共享引用,回溯后旧值被一起改掉)。
148
+ */
149
+ mounts: "__mounts",
98
150
  /**
99
151
  * `window auto|show|hide`:对话框显隐三态(UI 层据此控制对话层可见性;
100
152
  * 叙事语义归核心层,DOM 操作归 UI)。
@@ -125,3 +177,19 @@ export const SYS = {
125
177
  export const RESERVED_STATE_KEYS: ReadonlySet<string> = new Set(
126
178
  Object.values(SYS),
127
179
  );
180
+
181
+ /**
182
+ * 会话态键:进快照(回溯要用),**不进存档**。
183
+ *
184
+ * 这些键描述的是「引擎此刻在干什么」而不是「故事进行到哪」——正停着、正停在菜单里、
185
+ * 正在重放。读档等于会话重开,带着它们会让引擎一开局就停在暂停中(`advance` 直接
186
+ * `pause-active`),或以为玩家仍在菜单里。
187
+ *
188
+ * 落点唯一:`runtime/save/export.ts` 的导出过滤(活状态与档内历史检查点都从这里过一遍)。
189
+ * 快照不经过滤——回溯要恢复的正是「当时的会话态」。
190
+ */
191
+ export const SESSION_STATE_KEYS: ReadonlySet<string> = new Set([
192
+ SYS.paused,
193
+ SYS.menuReturn,
194
+ SYS.rollbackActive,
195
+ ]);
@@ -10,12 +10,19 @@
10
10
  /** 定时器句柄(不透明):由 {@link TimerPort.set} 返回,只能交回 {@link TimerPort.clear} */
11
11
  export type TimerHandle = { readonly brand: unique symbol };
12
12
 
13
- /** 定时器端口:延迟回调 + 取消。实现方保证句柄不可跨实例混用。 */
13
+ /** 定时器端口:延迟回调 + 取消 + 时刻。实现方保证句柄不可跨实例混用。 */
14
14
  export interface TimerPort {
15
15
  /** 安排 `ms` 毫秒后回调 `fn`;返回取消句柄 */
16
16
  set(fn: () => void, ms: number): TimerHandle;
17
17
  /** 取消已安排的回调;句柄已触发或已取消时为空操作 */
18
18
  clear(handle: TimerHandle): void;
19
+ /**
20
+ * 当前时刻(毫秒时间轴,只需单调不减)。
21
+ *
22
+ * 暂停要把「还剩多少毫秒」算出来,没有时刻就只能丢掉剩余量从头计。
23
+ * 可选:不提供时核心退回宿主时钟(`Date.now()`),无 JS 运行时的宿主自行注入。
24
+ */
25
+ now?(): number;
19
26
  }
20
27
 
21
28
  /**