@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.
- package/package.json +1 -1
- package/src/contracts/guard.ts +9 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/media/entities.ts +85 -3
- package/src/contracts/media/ports.ts +48 -0
- package/src/contracts/param-direction.ts +103 -0
- package/src/contracts/runtime/column-summary.ts +24 -0
- package/src/contracts/runtime/controls.ts +49 -0
- package/src/contracts/runtime/entities.ts +21 -0
- package/src/contracts/runtime/index.ts +2 -0
- package/src/contracts/runtime/reader.ts +28 -1
- package/src/contracts/runtime/state-keys.ts +62 -2
- package/src/contracts/runtime/timer.ts +8 -1
- package/src/contracts/save.ts +18 -1
- package/src/contracts/shell.ts +26 -0
- package/src/contracts/story/entities.ts +23 -8
- package/src/data/format/column.ts +33 -4
- package/src/data/index.ts +1 -0
- package/src/data/project/assemble.ts +37 -6
- package/src/data/project/conflict.ts +4 -3
- package/src/data/project/diff.ts +3 -3
- package/src/data/project/index.ts +6 -1
- package/src/data/project/naming.ts +11 -5
- package/src/data/project/serialize.ts +49 -16
- package/src/data/text/entry.ts +17 -9
- package/src/data/text/generate/command/index.ts +2 -1
- package/src/data/text/generate/command/media.ts +33 -2
- package/src/data/text/generate/command/ui.ts +29 -0
- package/src/data/text/generate/command.ts +10 -0
- package/src/data/text/parse/statement/flow.ts +1 -1
- package/src/data/text/parse/statement/index.ts +2 -1
- package/src/data/text/parse/statement/media.ts +91 -2
- package/src/data/text/parse/statement/ui.ts +47 -0
- package/src/data/text/parse/statement.ts +9 -0
- package/src/index.ts +8 -1
- package/src/runtime/dispatch.ts +24 -0
- package/src/runtime/engine/columns.ts +31 -0
- package/src/runtime/engine/forwarders.ts +7 -4
- package/src/runtime/engine/game-state.ts +29 -0
- package/src/runtime/engine/index.ts +2 -0
- package/src/runtime/engine/state.ts +13 -1
- package/src/runtime/engine.ts +46 -21
- package/src/runtime/expr/builtins.ts +1 -1
- package/src/runtime/expr/evaluate.ts +1 -1
- package/src/runtime/expr/index.ts +1 -1
- package/src/runtime/expr/interpolate.ts +1 -1
- package/src/runtime/expr/parser/cursor.ts +1 -1
- package/src/runtime/history/index.ts +1 -0
- package/src/runtime/history/rollback.ts +23 -8
- package/src/runtime/index.ts +9 -3
- package/src/runtime/internal/context.ts +25 -2
- package/src/runtime/internal/helpers.ts +18 -0
- package/src/runtime/internal/index.ts +9 -1
- package/src/runtime/internal/session.ts +11 -0
- package/src/runtime/internal/waiting.ts +51 -3
- package/src/runtime/opRegistry.ts +7 -0
- package/src/runtime/ops/dialog.ts +2 -11
- package/src/runtime/ops/flow.ts +1 -1
- package/src/runtime/ops/index.ts +11 -1
- package/src/runtime/ops/media/audio.ts +230 -0
- package/src/runtime/ops/media/index.ts +18 -0
- package/src/runtime/ops/media/instances.ts +312 -0
- package/src/runtime/ops/media/notify.ts +32 -0
- package/src/runtime/ops/media/shared.ts +133 -0
- package/src/runtime/ops/media/video.ts +118 -0
- package/src/runtime/ops/ui.ts +95 -0
- package/src/runtime/paramDirection.ts +35 -0
- package/src/runtime/save/export.ts +52 -6
- package/src/runtime/save/import.ts +21 -6
- package/src/runtime/scope.ts +17 -0
- package/src/runtime/session/index.ts +21 -0
- package/src/runtime/session/pause.ts +114 -0
- package/src/runtime/{waitingOps.ts → session/waitingOps.ts} +1 -1
- package/src/runtime/shell.ts +68 -0
- package/src/runtime/state/index.ts +2 -1
- package/src/runtime/state/reader.ts +32 -1
- package/src/runtime/state/writer.ts +1 -1
- package/src/shared/audio.ts +106 -0
- package/src/shared/guards.ts +38 -4
- package/src/shared/index.ts +3 -0
- package/src/shared/mounts.ts +42 -0
- package/src/shared/ui-name.ts +41 -0
- package/src/runtime/ops/media.ts +0 -274
- /package/src/runtime/{resolver.ts → expr/resolver.ts} +0 -0
- /package/src/runtime/{stateContract.ts → state/stateContract.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 媒体域共用:op 负载白名单与音量 / 淡出解析。
|
|
3
|
+
*
|
|
4
|
+
* 白名单是 fail-closed 的唯一来源——执行器只查这里,未知字段一律拒绝;
|
|
5
|
+
* 音量与淡出是音频与视频两族共用的窄化,口径必须一处(否则同一个
|
|
6
|
+
* `volume` 在两条命令上会有两种边界行为)。
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
SYS,
|
|
10
|
+
type AudioChannel,
|
|
11
|
+
type AudioChannelState,
|
|
12
|
+
} from "../../../contracts/index.ts";
|
|
13
|
+
import type { OpContext } from "../../internal/index.ts";
|
|
14
|
+
|
|
15
|
+
/** 音频 op 已知负载字段(未知字段 fail-closed) */
|
|
16
|
+
export const AUDIO_FIELDS: Record<string, ReadonlySet<string>> = {
|
|
17
|
+
bgm: new Set(["op", "resource", "volume", "loop", "fade", "restart"]),
|
|
18
|
+
se: new Set(["op", "resource", "volume"]),
|
|
19
|
+
ambient: new Set(["op", "resource", "volume", "loop", "fade", "restart"]),
|
|
20
|
+
stop_bgm: new Set(["op", "fade"]),
|
|
21
|
+
stop_ambient: new Set(["op", "fade"]),
|
|
22
|
+
stop_se: new Set(["op", "fade"]),
|
|
23
|
+
voice: new Set(["op", "resource", "volume", "auto_stop", "restart"]),
|
|
24
|
+
stop_voice: new Set(["op", "fade"]),
|
|
25
|
+
audio_play: new Set([
|
|
26
|
+
"op",
|
|
27
|
+
"inst",
|
|
28
|
+
"channel",
|
|
29
|
+
"resource",
|
|
30
|
+
"volume",
|
|
31
|
+
"loop",
|
|
32
|
+
"fade",
|
|
33
|
+
"restart",
|
|
34
|
+
]),
|
|
35
|
+
audio_stop: new Set(["op", "inst", "fade"]),
|
|
36
|
+
audio_volume: new Set(["op", "inst", "volume"]),
|
|
37
|
+
audio_pause: new Set(["op", "channel"]),
|
|
38
|
+
audio_resume: new Set(["op", "channel"]),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** 停止类 op → 目标常驻通道系统键 */
|
|
42
|
+
export const AUDIO_STOP_KEY: Record<string, string> = {
|
|
43
|
+
stop_bgm: SYS.audioBgm,
|
|
44
|
+
stop_ambient: SYS.audioAmbient,
|
|
45
|
+
stop_voice: SYS.audioVoice,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** 音频通道 op → 常驻通道系统键(se 为一次性触发,不在表内) */
|
|
49
|
+
export const AUDIO_CHANNEL_KEY: Record<string, string> = {
|
|
50
|
+
bgm: SYS.audioBgm,
|
|
51
|
+
ambient: SYS.audioAmbient,
|
|
52
|
+
voice: SYS.audioVoice,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* volume 三级解析:命令显式值 ?? 工程默认(`shell.audio.volumes[通道]`)?? 1。
|
|
57
|
+
*
|
|
58
|
+
* 顺序不能反:显式值是作者在这一条命令上的意图,工程默认是整部作品的口径,
|
|
59
|
+
* 内建 1 只在前两级都缺席时兜底(与「不指定 = 满音量」的既有语义逐字节一致)。
|
|
60
|
+
* 玩家偏好不在这里——它是渲染层最外层的乘算,混进来会让「换个工程,玩家设置
|
|
61
|
+
* 被这个工程的默认值改掉」。
|
|
62
|
+
*
|
|
63
|
+
* `channel` 缺省(视频族)表示没有工程默认级:视频音量只认命令与内建 1。
|
|
64
|
+
* 非有限数字一律 fail-closed(作者写错不能静默当满音量),越界按物理范围钳制。
|
|
65
|
+
*/
|
|
66
|
+
export function audioVolume(
|
|
67
|
+
ctx: OpContext,
|
|
68
|
+
raw: unknown,
|
|
69
|
+
op: string,
|
|
70
|
+
channel?: AudioChannel,
|
|
71
|
+
): number | null {
|
|
72
|
+
let explicit: number | undefined;
|
|
73
|
+
if (raw !== undefined) {
|
|
74
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
75
|
+
ctx.fail(`${op}-invalid-volume`, `${op} 的 volume 必须是有限数字`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
explicit = raw;
|
|
79
|
+
}
|
|
80
|
+
const fallback =
|
|
81
|
+
channel === undefined ? 1 : (ctx.audioConfig.volumes[channel] ?? 1);
|
|
82
|
+
return Math.min(1, Math.max(0, explicit ?? fallback));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** fade 窄化:缺省 0;负数/非有限数字 fail-closed */
|
|
86
|
+
export function audioFade(
|
|
87
|
+
ctx: OpContext,
|
|
88
|
+
raw: unknown,
|
|
89
|
+
op: string,
|
|
90
|
+
): number | null {
|
|
91
|
+
if (raw === undefined) return 0;
|
|
92
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
|
|
93
|
+
ctx.fail(`${op}-invalid-fade`, `${op} 的 fade 必须是非负有限数字(毫秒)`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return raw;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 播放态判定:暂停记账只记录「有东西可续播」的状态。
|
|
101
|
+
*
|
|
102
|
+
* `pause` 态本身不带资源、`stop` 态已经结束,两者都没有可写回的播放态。判据按契约
|
|
103
|
+
* 形状严格比对——记账表随快照 / 存档随行,读回来的可能是被外部改坏的值,把非 play
|
|
104
|
+
* 态当成 play 态会让恢复写回一条没有资源的播放态。
|
|
105
|
+
*/
|
|
106
|
+
export function isPlayState(value: unknown): value is AudioChannelState {
|
|
107
|
+
if (value === null || typeof value !== "object") return false;
|
|
108
|
+
const state = value as { kind?: unknown; resource?: unknown };
|
|
109
|
+
return state.kind === "play" && typeof state.resource === "string";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 读 `SYS.audioPaused` 暂停记账表:逐项只留 play 态,形状不合按空表处理。
|
|
114
|
+
*
|
|
115
|
+
* 同一张表按两套键空间分区存放:常驻通道项用通道名(`bgm` / `ambient` / `voice`),
|
|
116
|
+
* 音频实例项用 `inst:<实例id>`。这里原样返回全部键,由调用方按自己的键空间取值——
|
|
117
|
+
* 在读函数里按前缀过滤会让另一族读不到自己的项。
|
|
118
|
+
*
|
|
119
|
+
* 外部改坏的值一律静默过滤:一条脏值不该让整条暂停 / 恢复路径失败。
|
|
120
|
+
*/
|
|
121
|
+
export function readPausedPlayStates(
|
|
122
|
+
ctx: OpContext,
|
|
123
|
+
): Record<string, AudioChannelState> {
|
|
124
|
+
const value = ctx.get(SYS.audioPaused);
|
|
125
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
const out: Record<string, AudioChannelState> = {};
|
|
129
|
+
for (const [key, state] of Object.entries(value as Record<string, unknown>)) {
|
|
130
|
+
if (isPlayState(state)) out[key] = state;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 视频族:核心只写命令流(`__video`,seq 单调),渲染器按序执行。
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
SYS,
|
|
6
|
+
type StoryCommand,
|
|
7
|
+
type VideoCommand,
|
|
8
|
+
} from "../../../contracts/index.ts";
|
|
9
|
+
import type { Frame, OpContext } from "../../internal/index.ts";
|
|
10
|
+
import { audioVolume } from "./shared.ts";
|
|
11
|
+
|
|
12
|
+
/** 视频族已知负载字段(未知字段 fail-closed) */
|
|
13
|
+
const VIDEO_FIELDS: Record<string, ReadonlySet<string>> = {
|
|
14
|
+
video: new Set(["op", "resource", "volume", "loop", "z"]),
|
|
15
|
+
cutscene: new Set(["op", "resource", "volume", "skipable", "z"]),
|
|
16
|
+
seek_video: new Set(["op", "seconds"]),
|
|
17
|
+
pause_video: new Set(["op"]),
|
|
18
|
+
resume_video: new Set(["op"]),
|
|
19
|
+
stop_video: new Set(["op"]),
|
|
20
|
+
video_skipable: new Set(["op", "value"]),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 视频族:核心只写命令流(`__video`,seq 单调),渲染器按序执行。
|
|
25
|
+
* - `video` = 非阻塞播放(故事继续);`cutscene` = 阻塞过场(等待建立时提交检查点,
|
|
26
|
+
* 同 menu/wait/input;ended/跳过解除)。
|
|
27
|
+
* - `seek_video`/`pause_video`/`resume_video`/`stop_video` 为离散命令;
|
|
28
|
+
* `video_skipable` 设后续 video/cutscene 的缺省 skipable。
|
|
29
|
+
*/
|
|
30
|
+
export function execVideo(
|
|
31
|
+
ctx: OpContext,
|
|
32
|
+
frame: Frame,
|
|
33
|
+
cmd: StoryCommand,
|
|
34
|
+
cutscene = false,
|
|
35
|
+
): boolean {
|
|
36
|
+
const known = VIDEO_FIELDS[cmd.op]!;
|
|
37
|
+
const unknown = Object.keys(cmd).filter((k) => !known.has(k));
|
|
38
|
+
if (unknown.length > 0) {
|
|
39
|
+
ctx.fail(
|
|
40
|
+
`${cmd.op}-unknown-field`,
|
|
41
|
+
`${cmd.op} 未知负载字段:${unknown.join(", ")}`,
|
|
42
|
+
);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
ctx.videoSeq += 1;
|
|
46
|
+
const seq = ctx.videoSeq;
|
|
47
|
+
if (cmd.op === "video" || cmd.op === "cutscene") {
|
|
48
|
+
if (ctx.rejectBadInstanceZ(cmd)) return false; // 先拒非法 z(不动状态)
|
|
49
|
+
ctx.setInstanceZ(SYS.videoZ, cmd.z); // 视频层实例 z(宿主解析后交 VideoPort.setZIndex)
|
|
50
|
+
if (typeof cmd.resource !== "string" || cmd.resource === "") {
|
|
51
|
+
ctx.fail(
|
|
52
|
+
`${cmd.op}-invalid-resource`,
|
|
53
|
+
`${cmd.op} 需要非空 resource 字符串`,
|
|
54
|
+
);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const volume = audioVolume(ctx, cmd.volume, cmd.op);
|
|
58
|
+
if (volume === null) return false;
|
|
59
|
+
// skipable:cutscene 显式参数 > video_skipable 持久开关(缺省可跳)
|
|
60
|
+
const skipable =
|
|
61
|
+
cutscene && cmd.skipable !== undefined
|
|
62
|
+
? cmd.skipable === true
|
|
63
|
+
: ctx.get(SYS.videoSkipable) !== false;
|
|
64
|
+
ctx.setSystem(SYS.video, {
|
|
65
|
+
kind: "play",
|
|
66
|
+
resource: cmd.resource,
|
|
67
|
+
volume,
|
|
68
|
+
loop: cmd.loop === true,
|
|
69
|
+
cutscene,
|
|
70
|
+
skipable,
|
|
71
|
+
seq,
|
|
72
|
+
} satisfies VideoCommand);
|
|
73
|
+
if (cutscene) {
|
|
74
|
+
// 过场等待建立时提交检查点(同 menu/wait/input);重放期同坐标原位替换
|
|
75
|
+
ctx.setSystem(SYS.waiting, "video");
|
|
76
|
+
ctx.commitCheckpoint(ctx.takeSnapshot(ctx.checkpointCoord(frame)));
|
|
77
|
+
ctx.liveCheckpointed = true;
|
|
78
|
+
ctx.autoSaveAtCheckpoint(); // cutscene 等待画面建立 = auto_save 消费点
|
|
79
|
+
frame.index += 1;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (cmd.op === "seek_video") {
|
|
84
|
+
const seconds = cmd.seconds;
|
|
85
|
+
if (
|
|
86
|
+
typeof seconds !== "number" ||
|
|
87
|
+
!Number.isFinite(seconds) ||
|
|
88
|
+
seconds < 0
|
|
89
|
+
) {
|
|
90
|
+
ctx.fail(
|
|
91
|
+
"seek_video-invalid-seconds",
|
|
92
|
+
"seek_video 需要非负有限数字(秒)",
|
|
93
|
+
);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
ctx.setSystem(SYS.video, {
|
|
97
|
+
kind: "seek",
|
|
98
|
+
seconds,
|
|
99
|
+
seq,
|
|
100
|
+
} satisfies VideoCommand);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (cmd.op === "pause_video") {
|
|
104
|
+
ctx.setSystem(SYS.video, { kind: "pause", seq } satisfies VideoCommand);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (cmd.op === "resume_video") {
|
|
108
|
+
ctx.setSystem(SYS.video, { kind: "resume", seq } satisfies VideoCommand);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (cmd.op === "stop_video") {
|
|
112
|
+
ctx.setSystem(SYS.video, { kind: "stop", seq } satisfies VideoCommand);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
// video_skipable
|
|
116
|
+
ctx.setSystem(SYS.videoSkipable, cmd.value !== false);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 挂载的执行:把「哪个组件该不该出现在渲染树上、带什么入参」写成状态。
|
|
3
|
+
*
|
|
4
|
+
* 核心只写 `SYS.mounts` 这张表,不碰 DOM、不认识组件内部——挂载与卸载是宿主的活。
|
|
5
|
+
* `visible: false` 的语义是**从渲染树移除**(卸载),不是 `display: none`:
|
|
6
|
+
* 隐藏的组件不该继续跑生命周期与计时器。整表随快照 / 存档 / 回溯自动随行。
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
SYS,
|
|
10
|
+
type MountTable,
|
|
11
|
+
type StoryCommand,
|
|
12
|
+
} from "../../contracts/index.ts";
|
|
13
|
+
import { isMountTable, isUiName, UI_NAME_PATTERN } from "../../shared/index.ts";
|
|
14
|
+
import { findJsonValueError } from "../state/stateContract.ts";
|
|
15
|
+
import type { OpContext } from "../internal/index.ts";
|
|
16
|
+
|
|
17
|
+
/** `mount` 已知负载字段(未知字段 fail-closed) */
|
|
18
|
+
const MOUNT_OP_FIELDS: ReadonlySet<string> = new Set([
|
|
19
|
+
"op",
|
|
20
|
+
"name",
|
|
21
|
+
"visible",
|
|
22
|
+
"props",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** 读挂载表:形状不合(外部改坏)按空表处理,不让一条脏值卡死整个挂载域 */
|
|
26
|
+
function mountTableOf(ctx: OpContext): MountTable {
|
|
27
|
+
const value = ctx.get(SYS.mounts);
|
|
28
|
+
return isMountTable(value) ? value : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 挂载 / 卸载一个 UI 组件:`mount(hub, true)`、`mount(hub, false, { scene: "forest" })`。
|
|
33
|
+
*
|
|
34
|
+
* - `visible` 必填布尔:没有「切换」语义,显式写显隐才能让回溯读到确定的终值
|
|
35
|
+
* - `props` 可选:给了就**整份替换**(不是浅合并)——合并语义会让「删掉一个入参」
|
|
36
|
+
* 无法表达,而组件的入参集合是它的契约,得能完整指定
|
|
37
|
+
* - 整表新建后写回:**不得原地改** `table[name]`,否则回溯会拿到被污染的值
|
|
38
|
+
*/
|
|
39
|
+
export function execMount(ctx: OpContext, cmd: StoryCommand): boolean {
|
|
40
|
+
const unknown = Object.keys(cmd).filter((k) => !MOUNT_OP_FIELDS.has(k));
|
|
41
|
+
if (unknown.length > 0) {
|
|
42
|
+
ctx.fail(
|
|
43
|
+
"mount-unknown-field",
|
|
44
|
+
`mount 未知负载字段:${unknown.join(", ")}`,
|
|
45
|
+
);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const { name, visible } = cmd;
|
|
49
|
+
if (!isUiName(name)) {
|
|
50
|
+
ctx.fail(
|
|
51
|
+
"mount-invalid-name",
|
|
52
|
+
`mount 的 name 不合法:${JSON.stringify(name)}(须匹配 ${UI_NAME_PATTERN.source})`,
|
|
53
|
+
);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (typeof visible !== "boolean") {
|
|
57
|
+
ctx.fail(
|
|
58
|
+
"mount-invalid-visible",
|
|
59
|
+
`mount 的 visible 必须是布尔:${JSON.stringify(visible)}`,
|
|
60
|
+
);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const rawProps = cmd.props;
|
|
64
|
+
let props: Record<string, unknown> = {};
|
|
65
|
+
if (rawProps !== undefined) {
|
|
66
|
+
if (
|
|
67
|
+
typeof rawProps !== "object" ||
|
|
68
|
+
rawProps === null ||
|
|
69
|
+
Array.isArray(rawProps)
|
|
70
|
+
) {
|
|
71
|
+
ctx.fail(
|
|
72
|
+
"mount-invalid-props",
|
|
73
|
+
`mount 的 props 必须是对象:${JSON.stringify(rawProps)}`,
|
|
74
|
+
);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const unsafe = findJsonValueError(rawProps, "mount.props");
|
|
78
|
+
if (unsafe !== null) {
|
|
79
|
+
ctx.fail(
|
|
80
|
+
"mount-unsafe-props",
|
|
81
|
+
`mount 的 props 不是 JSON 安全的值——${unsafe}`,
|
|
82
|
+
);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
props = rawProps as Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
const table = mountTableOf(ctx);
|
|
88
|
+
const next: Record<
|
|
89
|
+
string,
|
|
90
|
+
{ visible: boolean; props: Record<string, unknown> }
|
|
91
|
+
> = { ...table };
|
|
92
|
+
next[name] = { visible, props };
|
|
93
|
+
ctx.setSystem(SYS.mounts, next satisfies MountTable);
|
|
94
|
+
return true;
|
|
95
|
+
}
|