@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
@@ -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,
@@ -26,7 +26,13 @@ const INTERACTION_FIELDS = new Set([
26
26
  "z", // 实例级 z(与 minigame 同层:外部系统整屏接管)
27
27
  ]);
28
28
 
29
- /** input(prompt + store)→ 进入 input 等待;options 选项式输入延后(解析层拒绝) */
29
+ /**
30
+ * input(prompt + store,可选 options 候选)→ 进入 input 等待。
31
+ *
32
+ * 候选值 `{ text, value }[]`:`text` 走翻译表(显示),`value` 原样进状态(玩家点选后
33
+ * 由命令面按普通输入提交)。没有候选时也写一个空数组,**不能跳过**——否则上一次输入
34
+ * 留下的候选会被这一轮读到(状态是跨命令持久的)。
35
+ */
30
36
  export function execInput(
31
37
  ctx: OpContext,
32
38
  frame: Frame,
@@ -41,6 +47,13 @@ export function execInput(
41
47
  SYS.inputPrompt,
42
48
  typeof cmd.prompt === "string" ? ctx.translate(cmd.prompt) : "",
43
49
  );
50
+ const candidates = Array.isArray(cmd.options)
51
+ ? (cmd.options as Array<{ text: string; value: string }>).map((o) => ({
52
+ text: ctx.translate(o.text),
53
+ value: o.value,
54
+ }))
55
+ : [];
56
+ ctx.setSystem(SYS.inputOptions, candidates);
44
57
  ctx.inputStore = cmd.store as string;
45
58
  ctx.setInstanceZ(SYS.choicesZ, cmd.z); // 输入形态的实例 z(choices 层)
46
59
  ctx.setSystem(SYS.waiting, "input");
@@ -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";
@@ -0,0 +1,312 @@
1
+ /**
2
+ * 音频实例族:`audio_play` / `audio_stop` / `audio_volume`。
3
+ *
4
+ * 实例与四通道**并存**,不是替换:通道是叙事语义(bgm / se / ambient / voice,
5
+ * 各有一档玩家偏好),实例是作者的编排单元(数量不定、名字自起、各自独立叠放)。
6
+ * 实例靠状态里的 `channel` 决定偏好归口,读侧乘的是同一个 `effectiveVolume`。
7
+ */
8
+ import {
9
+ AUDIO_INSTANCE_ID_PATTERN,
10
+ MAX_AUDIO_INSTANCES,
11
+ SYS,
12
+ isAudioChannel,
13
+ isAudioInstanceId,
14
+ isAudioInstanceTable,
15
+ type AudioChannel,
16
+ type AudioChannelState,
17
+ type AudioInstanceTable,
18
+ type AudioInstanceState,
19
+ type StoryCommand,
20
+ } from "../../../contracts/index.ts";
21
+ import type { OpContext } from "../../internal/index.ts";
22
+ import {
23
+ AUDIO_FIELDS,
24
+ audioFade,
25
+ audioVolume,
26
+ isPlayState,
27
+ readPausedPlayStates,
28
+ } from "./shared.ts";
29
+
30
+ /**
31
+ * 暂停记账里实例项的键前缀。
32
+ *
33
+ * 实例 id 与通道名同字符集(`bgm` / `se` / `ambient` / `voice` 都是合法实例名),
34
+ * 两者共用一张记账表时不能拿裸 id 当键:通道恢复会读到某个同名实例的播放态,
35
+ * 把别人的资源写回自己的通道。
36
+ */
37
+ const PAUSE_KEY_PREFIX = "inst:";
38
+
39
+ /** 读实例表:形状不合(外部改坏)按空表处理,不让一条脏值卡死整个音频域 */
40
+ function instanceTableOf(ctx: OpContext): AudioInstanceTable {
41
+ const value = ctx.get(SYS.audioInstances);
42
+ return isAudioInstanceTable(value) ? value : {};
43
+ }
44
+
45
+ /** 整表新建后写回:**不得原地改** `table[id].state`,否则回溯会拿到被污染的值 */
46
+ function writeInstance(
47
+ ctx: OpContext,
48
+ table: AudioInstanceTable,
49
+ instanceId: string,
50
+ channel: AudioChannel,
51
+ state: AudioChannelState,
52
+ ): void {
53
+ const next: Record<string, AudioInstanceState> = { ...table };
54
+ next[instanceId] = { channel, state };
55
+ ctx.setSystem(SYS.audioInstances, next satisfies AudioInstanceTable);
56
+ }
57
+
58
+ /**
59
+ * 音频实例 op 的执行:`audio_play` / `audio_stop` / `audio_volume`。
60
+ *
61
+ * 实例与四通道**并存**,不是替换:通道是叙事语义(bgm / se / ambient / voice,
62
+ * 各有一档玩家偏好),实例是作者的编排单元(数量不定、名字自起、各自独立叠放)。
63
+ * 实例靠状态里的 `channel` 决定偏好归口,读侧乘的是同一个 `effectiveVolume`。
64
+ *
65
+ * 三个 op 的分工:`audio_play` 创建或更新;`audio_stop` 停一个(缺 `inst` = 停全部);
66
+ * `audio_volume` **只改音量,不重播也不递增 seq**——音量的变化由值比较感知,
67
+ * 递增序号会让渲染层误判成一次重播,把正在放的那一轨打断。
68
+ */
69
+ export function execAudioInstance(ctx: OpContext, cmd: StoryCommand): boolean {
70
+ const known = AUDIO_FIELDS[cmd.op]!;
71
+ const unknown = Object.keys(cmd).filter((k) => !known.has(k));
72
+ if (unknown.length > 0) {
73
+ ctx.fail(
74
+ `${cmd.op}-unknown-field`,
75
+ `${cmd.op} 未知负载字段:${unknown.join(", ")}`,
76
+ );
77
+ return false;
78
+ }
79
+ const table = instanceTableOf(ctx);
80
+ if (cmd.op === "audio_stop") return stopInstance(ctx, table, cmd);
81
+ if (cmd.op === "audio_volume") return setInstanceVolume(ctx, table, cmd);
82
+ return playInstance(ctx, table, cmd);
83
+ }
84
+
85
+ /** `audio_play`:创建或更新一个实例(同资源 = 更新,换资源 = 重播) */
86
+ function playInstance(
87
+ ctx: OpContext,
88
+ table: AudioInstanceTable,
89
+ cmd: StoryCommand,
90
+ ): boolean {
91
+ const instanceId = cmd.inst;
92
+ if (!isAudioInstanceId(instanceId)) {
93
+ ctx.fail(
94
+ "audio_play-invalid-instance",
95
+ `audio_play 的 inst 不合法:${JSON.stringify(instanceId)}(须匹配 ${AUDIO_INSTANCE_ID_PATTERN.source})`,
96
+ );
97
+ return false;
98
+ }
99
+ const channel = cmd.channel;
100
+ if (!isAudioChannel(channel)) {
101
+ ctx.fail(
102
+ "audio_play-invalid-channel",
103
+ `audio_play 的 channel 不合法:${JSON.stringify(channel)}(须为 bgm / se / ambient / voice 之一)`,
104
+ );
105
+ return false;
106
+ }
107
+ if (typeof cmd.resource !== "string" || cmd.resource === "") {
108
+ ctx.fail(
109
+ "audio_play-invalid-resource",
110
+ "audio_play 需要非空 resource 字符串",
111
+ );
112
+ return false;
113
+ }
114
+ const previous = table[instanceId];
115
+ if (
116
+ previous === undefined &&
117
+ Object.keys(table).length >= MAX_AUDIO_INSTANCES
118
+ ) {
119
+ // 上限在这里先拦一次(判据里也有,但那时已经写进存档了,错误发现得太晚)
120
+ ctx.fail(
121
+ "audio_play-instance-limit",
122
+ `音频实例已达上限 ${MAX_AUDIO_INSTANCES}:${instanceId} 无处安放(先 audio_stop 掉不用的实例)`,
123
+ );
124
+ return false;
125
+ }
126
+ const volume = audioVolume(ctx, cmd.volume, "audio_play", channel);
127
+ if (volume === null) return false;
128
+ const fade = audioFade(ctx, cmd.fade, "audio_play");
129
+ if (fade === null) return false;
130
+ const restart = cmd.restart === true;
131
+ const sameTrack =
132
+ previous !== undefined &&
133
+ previous.state.kind === "play" &&
134
+ previous.state.resource === cmd.resource;
135
+ const state: AudioChannelState = {
136
+ kind: "play",
137
+ resource: cmd.resource,
138
+ volume,
139
+ loop: cmd.loop !== false,
140
+ fadeMs: fade,
141
+ };
142
+ if (restart || !sameTrack) {
143
+ // 新实例 / 换资源 / 显式重播都要给一个新序号:渲染层据此判定「这是一次新的起播」
144
+ ctx.mediaSeq += 1;
145
+ state.seq = ctx.mediaSeq;
146
+ if (restart) state.restart = true;
147
+ } else if (
148
+ previous.state.kind === "play" &&
149
+ previous.state.seq !== undefined
150
+ ) {
151
+ // 同资源原地更新:**沿用旧序号**。沿用而不是省掉,是为了让「这一轨还是那一轨」
152
+ // 在状态里始终可辨认;不递增则保证渲染层不会把它当成一次新起播。
153
+ state.seq = previous.state.seq;
154
+ }
155
+ writeInstance(ctx, table, instanceId, channel, state);
156
+ return true;
157
+ }
158
+
159
+ /** `audio_stop`:停一个实例;缺 `inst` = 停全部实例 */
160
+ function stopInstance(
161
+ ctx: OpContext,
162
+ table: AudioInstanceTable,
163
+ cmd: StoryCommand,
164
+ ): boolean {
165
+ const fade = audioFade(ctx, cmd.fade, "audio_stop");
166
+ if (fade === null) return false;
167
+ const ids = Object.keys(table);
168
+ if (cmd.inst === undefined) {
169
+ if (ids.length === 0) return true; // 没有实例可停:不是错误
170
+ const next: Record<string, AudioInstanceState> = { ...table };
171
+ for (const id of ids) {
172
+ ctx.mediaSeq += 1;
173
+ next[id] = {
174
+ channel: table[id]!.channel,
175
+ // 每个实例各领一个序号:连续两次全停必须是两次独立事件
176
+ state: { kind: "stop", fadeMs: fade, seq: ctx.mediaSeq },
177
+ };
178
+ }
179
+ ctx.setSystem(SYS.audioInstances, next satisfies AudioInstanceTable);
180
+ return true;
181
+ }
182
+ const instanceId = cmd.inst;
183
+ if (!isAudioInstanceId(instanceId)) {
184
+ ctx.fail(
185
+ "audio_stop-invalid-instance",
186
+ `audio_stop 的 inst 不合法:${JSON.stringify(instanceId)}(须匹配 ${AUDIO_INSTANCE_ID_PATTERN.source})`,
187
+ );
188
+ return false;
189
+ }
190
+ const previous = table[instanceId];
191
+ if (previous === undefined) {
192
+ ctx.fail(
193
+ "audio_stop-unknown-instance",
194
+ `audio_stop 找不到实例 ${instanceId}(当前实例:${ids.join(", ") || "无"})`,
195
+ );
196
+ return false;
197
+ }
198
+ ctx.mediaSeq += 1;
199
+ writeInstance(ctx, table, instanceId, previous.channel, {
200
+ kind: "stop",
201
+ fadeMs: fade,
202
+ seq: ctx.mediaSeq,
203
+ });
204
+ return true;
205
+ }
206
+
207
+ /** `audio_volume`:只改音量(双向闭环入口),不重播、不递增 seq */
208
+ function setInstanceVolume(
209
+ ctx: OpContext,
210
+ table: AudioInstanceTable,
211
+ cmd: StoryCommand,
212
+ ): boolean {
213
+ const instanceId = cmd.inst;
214
+ if (!isAudioInstanceId(instanceId)) {
215
+ ctx.fail(
216
+ "audio_volume-invalid-instance",
217
+ `audio_volume 的 inst 不合法:${JSON.stringify(instanceId)}(须匹配 ${AUDIO_INSTANCE_ID_PATTERN.source})`,
218
+ );
219
+ return false;
220
+ }
221
+ const previous = table[instanceId];
222
+ if (previous === undefined) {
223
+ ctx.fail(
224
+ "audio_volume-unknown-instance",
225
+ `audio_volume 找不到实例 ${instanceId}(当前实例:${Object.keys(table).join(", ") || "无"})`,
226
+ );
227
+ return false;
228
+ }
229
+ if (cmd.volume === undefined) {
230
+ ctx.fail("audio_volume-missing-volume", "audio_volume 需要 volume");
231
+ return false;
232
+ }
233
+ if (previous.state.kind !== "play") {
234
+ ctx.fail(
235
+ "audio_volume-instance-stopped",
236
+ `实例 ${instanceId} 当前是停止状态:先 audio_play 起播再调音量`,
237
+ );
238
+ return false;
239
+ }
240
+ const volume = audioVolume(ctx, cmd.volume, "audio_volume", previous.channel);
241
+ if (volume === null) return false;
242
+ writeInstance(ctx, table, instanceId, previous.channel, {
243
+ ...previous.state,
244
+ volume,
245
+ });
246
+ return true;
247
+ }
248
+
249
+ /**
250
+ * 暂停全部实例:正在出声的实例转 `pause` 态,暂停前的播放态进 `SYS.audioPaused`。
251
+ *
252
+ * 常驻四通道由 `audio_pause` 覆盖,实例是另一张表,必须单独走一遍——否则
253
+ * 「引擎暂停」只让 BGM 收声,故事里正在放的环境音实例照旧出声。
254
+ * 记账与通道共用同一张表(同一概念一处声明),实例键加 `inst:` 前缀区分归属。
255
+ *
256
+ * 不经 op、直接改状态:这是引擎级暂停的内部步骤,不产生命令面的语义
257
+ * (作者手写的 `audio_pause` 只作用于四通道,暂停态仍由同一把「引擎已暂停」的闸门拦住推进)。
258
+ */
259
+ export function pauseAudioInstances(ctx: OpContext): void {
260
+ const table = instanceTableOf(ctx);
261
+ const ids = Object.keys(table);
262
+ if (ids.length === 0) return;
263
+ const paused = readPausedPlayStates(ctx);
264
+ const next: Record<string, AudioInstanceState> = { ...table };
265
+ let changed = false;
266
+ for (const id of ids) {
267
+ const entry = table[id];
268
+ if (entry === undefined || !isPlayState(entry.state)) continue;
269
+ ctx.mediaSeq += 1;
270
+ paused[PAUSE_KEY_PREFIX + id] = entry.state;
271
+ next[id] = {
272
+ channel: entry.channel,
273
+ state: { kind: "pause", seq: ctx.mediaSeq } satisfies AudioChannelState,
274
+ };
275
+ changed = true;
276
+ }
277
+ if (!changed) return;
278
+ ctx.setSystem(SYS.audioInstances, next satisfies AudioInstanceTable);
279
+ ctx.setSystem(SYS.audioPaused, paused);
280
+ }
281
+
282
+ /**
283
+ * 恢复全部实例:按记账写回暂停前的播放态并递增 `seq`。
284
+ *
285
+ * 只认 `pause` 态:暂停期间被命令改成 `stop` 的实例不复活——留着一条「该出声却
286
+ * 永远不出声」的实例比让它停着更糟,与通道恢复同一口径。
287
+ */
288
+ export function resumeAudioInstances(ctx: OpContext): void {
289
+ const table = instanceTableOf(ctx);
290
+ const ids = Object.keys(table);
291
+ if (ids.length === 0) return;
292
+ const paused = readPausedPlayStates(ctx);
293
+ const next: Record<string, AudioInstanceState> = { ...table };
294
+ let changed = false;
295
+ for (const id of ids) {
296
+ const entry = table[id];
297
+ if (entry === undefined || entry.state.kind !== "pause") continue;
298
+ const key = PAUSE_KEY_PREFIX + id;
299
+ const resumed = paused[key];
300
+ if (resumed === undefined) continue;
301
+ delete paused[key];
302
+ ctx.mediaSeq += 1;
303
+ next[id] = {
304
+ channel: entry.channel,
305
+ state: { ...resumed, seq: ctx.mediaSeq },
306
+ };
307
+ changed = true;
308
+ }
309
+ if (!changed) return;
310
+ ctx.setSystem(SYS.audioInstances, next satisfies AudioInstanceTable);
311
+ ctx.setSystem(SYS.audioPaused, paused);
312
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 出站通知:`notify` 只发事件,不碰状态(覆盖层的 toast 归宿主渲染)。
3
+ */
4
+ import {
5
+ SYS,
6
+ type OutboundEvent,
7
+ type OutboundPayload,
8
+ type StoryCommand,
9
+ } from "../../../contracts/index.ts";
10
+ import { interpolateText } from "../../expr/index.ts";
11
+ import type { OpContext } from "../../internal/index.ts";
12
+
13
+ /** notify:出站 toast 事件(覆盖层);文本插值与 say 同语义 */
14
+ export function execNotify(ctx: OpContext, cmd: StoryCommand): void {
15
+ if (ctx.rejectBadInstanceZ(cmd)) return; // 先拒非法 z(不动状态)
16
+ const { text, errors } = interpolateText(
17
+ ctx.translate(cmd.text as string),
18
+ ctx.resolveName,
19
+ ctx.draw,
20
+ );
21
+ for (const e of errors)
22
+ ctx.fail(e.code, ctx.texts.interpolationFallback(e.message));
23
+ ctx.setInstanceZ(SYS.notificationsZ, cmd.z); // 本条通知的实例 z(notifications 层)
24
+ const payload: OutboundPayload = {
25
+ kind: "notify",
26
+ text,
27
+ ...(typeof cmd.type === "string" ? { notifyType: cmd.type } : {}),
28
+ ...(typeof cmd.duration === "number" ? { duration: cmd.duration } : {}),
29
+ };
30
+ const event: OutboundEvent = { v: 1, kind: "event", payload };
31
+ for (const listener of ctx.eventListeners) listener(event);
32
+ }