@lingfan-engine/core 0.2.0 → 0.3.1
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
package/src/shared/guards.ts
CHANGED
|
@@ -61,17 +61,51 @@ export function isPlainObject(
|
|
|
61
61
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* 是否是契约承认的场景类型(**只认 `game` / `ui`**)。
|
|
66
|
+
*
|
|
67
|
+
* 调用点都在「拒绝非法输入」的位置:故事文件里写别的值一律 fail-closed 报错,
|
|
68
|
+
* 不静默当剧情——那种错要等存档/回溯出问题才发现,代价极高。
|
|
69
|
+
*/
|
|
65
70
|
export function isSceneType(value: unknown): value is SceneType {
|
|
66
|
-
return value === "game" || value === "
|
|
71
|
+
return value === "game" || value === "ui";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 把外部输入的场景类型拼写归一为契约值:`game` / `ui`。
|
|
76
|
+
*
|
|
77
|
+
* **单一归一函数**:解析层已用 `isSceneType` fail-closed 拒绝非法值,
|
|
78
|
+
* 这里只负责「缺省」这一种合法输入——`undefined` → `game`。
|
|
79
|
+
*
|
|
80
|
+
* 为什么仍单独成函数:`type` 的缺省语义(不写 = 剧情)在 JSON 解析、DSL 解析、
|
|
81
|
+
* 读侧兜底三处都要用,散写三份 `?? "game"` 会让「缺省是什么」出现三个答案。
|
|
82
|
+
*
|
|
83
|
+
* 注意:**归一是读侧兜底,不是校验**。故事文件里的非法 `type` 要 fail-closed 报错,
|
|
84
|
+
* 那是解析层(`columnShapeDefect`)的职责,不靠这里静默吃掉。
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeSceneType(raw: unknown): SceneType {
|
|
87
|
+
return isSceneType(raw) ? raw : "game";
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
/**
|
|
70
|
-
* 该列是否参与历史与存档:`type` 缺省按 `game` 算,`
|
|
91
|
+
* 该列是否参与历史与存档:`type` 缺省按 `game` 算,`ui` 不参与。
|
|
71
92
|
*
|
|
72
93
|
* **单一判定点**——引擎的写入守卫与编辑器的章节分组都调用这里,谁都不许另写一份,
|
|
73
94
|
* 否则两侧对「哪些列进存档」的理解会分叉。
|
|
74
95
|
*/
|
|
75
96
|
export function isReplayableColumn(column: Pick<StoryColumn, "type">): boolean {
|
|
76
|
-
return (column
|
|
97
|
+
return sceneTypeOf(column) === "game";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 列的**归一化**运行语义类型:缺省(以及解析层已 fail-closed 的非法值)一律取 `game`。
|
|
102
|
+
*
|
|
103
|
+
* **为什么需要它**:`isReplayableColumn` 只回答「参不参与存档」,回答不了「是哪一种」。
|
|
104
|
+
* 宿主列清单与状态读口要的是具体类型,若各自写 `column.type ?? "game"`,
|
|
105
|
+
* 缺省口径就分叉了。归一口径本身归 `normalizeSceneType`,这里只是它的列级入口。
|
|
106
|
+
*
|
|
107
|
+
* 注意:**这是读侧归一**。故事文件里的非法 `type` 由解析层拒收,不靠这里兜底。
|
|
108
|
+
*/
|
|
109
|
+
export function sceneTypeOf(column: Pick<StoryColumn, "type">): SceneType {
|
|
110
|
+
return normalizeSceneType(column.type);
|
|
77
111
|
}
|
package/src/shared/index.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂载表的读入判据:把运行期到手的 `unknown` 收窄成契约里的挂载表。
|
|
3
|
+
*
|
|
4
|
+
* 契约层只声明形态,挂载表却可能来自存档、来自被改坏的工程文件,
|
|
5
|
+
* 所以每次读入都得先判后用(与 `audio.ts` 的实例表同一套纪律:判不过就由调用方
|
|
6
|
+
* fail-closed,这里不抛错也不猜默认值)。
|
|
7
|
+
*
|
|
8
|
+
* 三条规则一次判清,缺一条都会让坏数据漏进渲染层:
|
|
9
|
+
* - 表是普通对象(数组与 `null` 不算——数组会把下标当 UI 名)
|
|
10
|
+
* - 键合 UI 名形态(名字同时是宿主预加载键与目录名,形态必须统一)
|
|
11
|
+
* - 值只有 `visible: boolean` 与 `props: 普通对象` 两项
|
|
12
|
+
*/
|
|
13
|
+
import type { MountEntry, MountTable } from "../contracts/runtime/index.ts";
|
|
14
|
+
import { isPlainObject } from "./guards.ts";
|
|
15
|
+
import { isUiName } from "./ui-name.ts";
|
|
16
|
+
|
|
17
|
+
/** 是否是合形态的挂载记录 */
|
|
18
|
+
export function isMountEntry(value: unknown): value is MountEntry {
|
|
19
|
+
if (!isPlainObject(value)) return false;
|
|
20
|
+
if (typeof value.visible !== "boolean") return false;
|
|
21
|
+
return isPlainObject(value.props);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 是否是合形态的挂载表:普通对象 + 键合 UI 名 + 值合记录 */
|
|
25
|
+
export function isMountTable(value: unknown): value is MountTable {
|
|
26
|
+
if (!isPlainObject(value)) return false;
|
|
27
|
+
for (const name of Object.keys(value)) {
|
|
28
|
+
if (!isUiName(name)) return false;
|
|
29
|
+
if (!isMountEntry(value[name])) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 取挂载表的 UI 名清单(码点序):不合形态的表按空表处理。
|
|
36
|
+
*
|
|
37
|
+
* 渲染层按名字排序后下发才能保证「同一份状态 → 同一串挂载动作」,
|
|
38
|
+
* 否则对象键序会让同一存档在两台机器上挂出不同顺序的组件。
|
|
39
|
+
*/
|
|
40
|
+
export function mountNames(value: unknown): readonly string[] {
|
|
41
|
+
return isMountTable(value) ? Object.keys(value).sort() : [];
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列级 UI 名字的形态与定序:名字 = `src/ui/<名>/` 的目录名。
|
|
3
|
+
*
|
|
4
|
+
* 为什么名字要卡形态:名字同时是**产物里的字符串**、宿主的**预加载键**、以及
|
|
5
|
+
* 目录/文件名的来源。不卡形态的话,`Hub` 与 `hub` 会在不同文件系统上落到同一个目录、
|
|
6
|
+
* 却成为两个不同的产物键,产物与实现就对不上了。
|
|
7
|
+
*
|
|
8
|
+
* 形态与扩展 id / 玩法系统 id 同源(`SHORT_ID_PATTERN`):同一个系统不出现两种写法,
|
|
9
|
+
* 且名字作为产物字段时仍然短小可读。
|
|
10
|
+
*/
|
|
11
|
+
import { SHORT_ID_PATTERN } from "../contracts/identifiers.ts";
|
|
12
|
+
import { compareOrdinal } from "./ordering.ts";
|
|
13
|
+
|
|
14
|
+
/** 合法 UI 名:小写字母开头,其后小写字母 / 数字 / `_` / `-`,总长 1..32 */
|
|
15
|
+
export const UI_NAME_PATTERN = SHORT_ID_PATTERN;
|
|
16
|
+
|
|
17
|
+
/** 是否为合法 UI 名(类型收窄到 `string`,可直接用于过滤未校验的 JSON 数组) */
|
|
18
|
+
export function isUiName(value: unknown): value is string {
|
|
19
|
+
return typeof value === "string" && UI_NAME_PATTERN.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 归一列级 `uis`:**去重 + 按名字码点序**。
|
|
24
|
+
*
|
|
25
|
+
* 为什么必须归一:`uis` 是产物字节的一部分,而它的来源(导入语句的书写顺序、
|
|
26
|
+
* 文件系统的目录枚举顺序)在不同机器上不一样。不归一的产物会「同源不同字节」,
|
|
27
|
+
* 缓存与逐字节比对全部失效。码点序(而非 locale 序)才与运行环境的语言无关。
|
|
28
|
+
*
|
|
29
|
+
* 这是全链路唯一的定序点:解析、汇编、写出三处都调这里,不各写一份比较器。
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeUiNames(names: readonly string[]): string[] {
|
|
32
|
+
const seen = new Set<string>();
|
|
33
|
+
const unique: string[] = [];
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
if (seen.has(name)) continue;
|
|
36
|
+
seen.add(name);
|
|
37
|
+
unique.push(name);
|
|
38
|
+
}
|
|
39
|
+
unique.sort(compareOrdinal);
|
|
40
|
+
return unique;
|
|
41
|
+
}
|
package/src/runtime/ops/media.ts
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 媒体命令的执行:音频通道、视频命令流、出站通知。
|
|
3
|
-
*
|
|
4
|
-
* 这一族的共同点是「核心只写状态,播放交给宿主」——音频写四通道的通道对象,
|
|
5
|
-
* 视频写单调递增的命令流,通知只发出站事件。渲染器按状态与事件执行,
|
|
6
|
-
* 核心不持有播放器,也不关心播放是否成功。
|
|
7
|
-
*/
|
|
8
|
-
import {
|
|
9
|
-
SYS,
|
|
10
|
-
type AudioChannelState,
|
|
11
|
-
type OutboundEvent,
|
|
12
|
-
type OutboundPayload,
|
|
13
|
-
type StoryCommand,
|
|
14
|
-
type VideoCommand,
|
|
15
|
-
} from "../../contracts/index.ts";
|
|
16
|
-
import { interpolateText } from "../expr/index.ts";
|
|
17
|
-
import type { Frame, OpContext } from "../internal/index.ts";
|
|
18
|
-
|
|
19
|
-
/** 音频 op 已知负载字段(未知字段 fail-closed) */
|
|
20
|
-
const AUDIO_FIELDS: Record<string, ReadonlySet<string>> = {
|
|
21
|
-
bgm: new Set(["op", "resource", "volume", "loop", "fade", "restart"]),
|
|
22
|
-
se: new Set(["op", "resource", "volume"]),
|
|
23
|
-
ambient: new Set(["op", "resource", "volume", "loop", "fade", "restart"]),
|
|
24
|
-
stop_bgm: new Set(["op", "fade"]),
|
|
25
|
-
stop_ambient: new Set(["op", "fade"]),
|
|
26
|
-
voice: new Set(["op", "resource", "volume", "auto_stop", "restart"]),
|
|
27
|
-
stop_voice: new Set(["op", "fade"]),
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/** 停止类 op → 目标常驻通道系统键 */
|
|
31
|
-
const AUDIO_STOP_KEY: Record<string, string> = {
|
|
32
|
-
stop_bgm: SYS.audioBgm,
|
|
33
|
-
stop_ambient: SYS.audioAmbient,
|
|
34
|
-
stop_voice: SYS.audioVoice,
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/** 音频通道 op → 常驻通道系统键(se 为一次性触发,不在表内) */
|
|
38
|
-
const AUDIO_CHANNEL_KEY: Record<string, string> = {
|
|
39
|
-
bgm: SYS.audioBgm,
|
|
40
|
-
ambient: SYS.audioAmbient,
|
|
41
|
-
voice: SYS.audioVoice,
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** 视频族已知负载字段(未知字段 fail-closed) */
|
|
45
|
-
const VIDEO_FIELDS: Record<string, ReadonlySet<string>> = {
|
|
46
|
-
video: new Set(["op", "resource", "volume", "loop", "z"]),
|
|
47
|
-
cutscene: new Set(["op", "resource", "volume", "skipable", "z"]),
|
|
48
|
-
seek_video: new Set(["op", "seconds"]),
|
|
49
|
-
pause_video: new Set(["op"]),
|
|
50
|
-
resume_video: new Set(["op"]),
|
|
51
|
-
stop_video: new Set(["op"]),
|
|
52
|
-
video_skipable: new Set(["op", "value"]),
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* 四音频通道:核心只写状态,播放由 UI 适配器落地。
|
|
57
|
-
* - bgm/ambient/voice 为常驻通道(写状态对象,随快照/存档随行);se 为一次性触发(单调 seq)
|
|
58
|
-
* - 同资源重写保留播放位置:重放不打断当前曲目(回滚 seek / 读档续播)
|
|
59
|
-
* - stop_* 写 stop 形态(带淡出参数);未知字段/非法负载 fail-closed
|
|
60
|
-
*/
|
|
61
|
-
export function execAudio(ctx: OpContext, cmd: StoryCommand): boolean {
|
|
62
|
-
const unknown = Object.keys(cmd).filter(
|
|
63
|
-
(k) => !(AUDIO_FIELDS[cmd.op]?.has(k) ?? false),
|
|
64
|
-
);
|
|
65
|
-
if (unknown.length > 0) {
|
|
66
|
-
ctx.fail(
|
|
67
|
-
`${cmd.op}-unknown-field`,
|
|
68
|
-
`${cmd.op} 未知负载字段:${unknown.join(", ")}`,
|
|
69
|
-
);
|
|
70
|
-
return false;
|
|
71
|
-
}
|
|
72
|
-
const fade = audioFade(ctx, cmd.fade, cmd.op);
|
|
73
|
-
if (fade === null) return false;
|
|
74
|
-
if (
|
|
75
|
-
cmd.op === "stop_bgm" ||
|
|
76
|
-
cmd.op === "stop_ambient" ||
|
|
77
|
-
cmd.op === "stop_voice"
|
|
78
|
-
) {
|
|
79
|
-
// 清通道为停止形态(淡出参数随状态走,不被丢弃)
|
|
80
|
-
const stopKey = AUDIO_STOP_KEY[cmd.op]!;
|
|
81
|
-
ctx.setSystem(stopKey, {
|
|
82
|
-
kind: "stop",
|
|
83
|
-
fadeMs: fade,
|
|
84
|
-
} satisfies AudioChannelState);
|
|
85
|
-
// 停止背景乐:播放位置归零(帧级键静默写)
|
|
86
|
-
if (cmd.op === "stop_bgm") ctx.state.set(SYS.bgmPosition, 0);
|
|
87
|
-
return true;
|
|
88
|
-
}
|
|
89
|
-
if (typeof cmd.resource !== "string" || cmd.resource === "") {
|
|
90
|
-
ctx.fail(
|
|
91
|
-
`${cmd.op}-invalid-resource`,
|
|
92
|
-
`${cmd.op} 需要非空 resource 字符串`,
|
|
93
|
-
);
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
const volume = audioVolume(ctx, cmd.volume, cmd.op);
|
|
97
|
-
if (volume === null) return false;
|
|
98
|
-
if (cmd.op === "se") {
|
|
99
|
-
ctx.mediaSeq += 1;
|
|
100
|
-
ctx.setSystem(SYS.audioSe, {
|
|
101
|
-
kind: "play",
|
|
102
|
-
resource: cmd.resource,
|
|
103
|
-
volume,
|
|
104
|
-
loop: false,
|
|
105
|
-
fadeMs: 0,
|
|
106
|
-
seq: ctx.mediaSeq,
|
|
107
|
-
} satisfies AudioChannelState);
|
|
108
|
-
return true;
|
|
109
|
-
}
|
|
110
|
-
const channelKey = AUDIO_CHANNEL_KEY[cmd.op]!;
|
|
111
|
-
const previous = ctx.get(channelKey) as AudioChannelState | null | undefined;
|
|
112
|
-
const restart = cmd.restart === true;
|
|
113
|
-
if (cmd.op === "bgm") {
|
|
114
|
-
// 首播/换曲/显式重播归零;同曲静默续播(回滚 seek / 读档续播)。
|
|
115
|
-
// 帧级键静默写(高频键不进事件流),UI 经通道事件重读位置。
|
|
116
|
-
const sameTrack =
|
|
117
|
-
previous?.kind === "play" && previous.resource === cmd.resource;
|
|
118
|
-
if (!sameTrack || restart) ctx.state.set(SYS.bgmPosition, 0);
|
|
119
|
-
}
|
|
120
|
-
const state: AudioChannelState = {
|
|
121
|
-
kind: "play",
|
|
122
|
-
resource: cmd.resource,
|
|
123
|
-
volume,
|
|
124
|
-
loop: cmd.op === "voice" ? false : cmd.loop !== false,
|
|
125
|
-
fadeMs: fade,
|
|
126
|
-
};
|
|
127
|
-
if (cmd.op === "voice") state.autoStop = cmd.auto_stop !== false;
|
|
128
|
-
if (restart) {
|
|
129
|
-
// 单调序号:同曲连续两次 restart 也是两次独立事件(渲染层据此重播)
|
|
130
|
-
ctx.mediaSeq += 1;
|
|
131
|
-
state.restart = true;
|
|
132
|
-
state.seq = ctx.mediaSeq;
|
|
133
|
-
}
|
|
134
|
-
ctx.setSystem(channelKey, state);
|
|
135
|
-
return true;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** volume 窄化:缺省 1;非有限数字 fail-closed,越界按物理范围钳制 */
|
|
139
|
-
function audioVolume(ctx: OpContext, raw: unknown, op: string): number | null {
|
|
140
|
-
if (raw === undefined) return 1;
|
|
141
|
-
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
|
142
|
-
ctx.fail(`${op}-invalid-volume`, `${op} 的 volume 必须是有限数字`);
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
return Math.min(1, Math.max(0, raw));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** fade 窄化:缺省 0;负数/非有限数字 fail-closed */
|
|
149
|
-
function audioFade(ctx: OpContext, raw: unknown, op: string): number | null {
|
|
150
|
-
if (raw === undefined) return 0;
|
|
151
|
-
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
|
|
152
|
-
ctx.fail(`${op}-invalid-fade`, `${op} 的 fade 必须是非负有限数字(毫秒)`);
|
|
153
|
-
return null;
|
|
154
|
-
}
|
|
155
|
-
return raw;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* 视频族:核心只写命令流(`__video`,seq 单调),渲染器按序执行。
|
|
160
|
-
* - `video` = 非阻塞播放(故事继续);`cutscene` = 阻塞过场(等待建立时提交检查点,
|
|
161
|
-
* 同 menu/wait/input;ended/跳过解除)。
|
|
162
|
-
* - `seek_video`/`pause_video`/`resume_video`/`stop_video` 为离散命令;
|
|
163
|
-
* `video_skipable` 设后续 video/cutscene 的缺省 skipable。
|
|
164
|
-
*/
|
|
165
|
-
export function execVideo(
|
|
166
|
-
ctx: OpContext,
|
|
167
|
-
frame: Frame,
|
|
168
|
-
cmd: StoryCommand,
|
|
169
|
-
cutscene = false,
|
|
170
|
-
): boolean {
|
|
171
|
-
const known = VIDEO_FIELDS[cmd.op]!;
|
|
172
|
-
const unknown = Object.keys(cmd).filter((k) => !known.has(k));
|
|
173
|
-
if (unknown.length > 0) {
|
|
174
|
-
ctx.fail(
|
|
175
|
-
`${cmd.op}-unknown-field`,
|
|
176
|
-
`${cmd.op} 未知负载字段:${unknown.join(", ")}`,
|
|
177
|
-
);
|
|
178
|
-
return false;
|
|
179
|
-
}
|
|
180
|
-
ctx.videoSeq += 1;
|
|
181
|
-
const seq = ctx.videoSeq;
|
|
182
|
-
if (cmd.op === "video" || cmd.op === "cutscene") {
|
|
183
|
-
if (ctx.rejectBadInstanceZ(cmd)) return false; // 先拒非法 z(不动状态)
|
|
184
|
-
ctx.setInstanceZ(SYS.videoZ, cmd.z); // 视频层实例 z(宿主解析后交 VideoPort.setZIndex)
|
|
185
|
-
if (typeof cmd.resource !== "string" || cmd.resource === "") {
|
|
186
|
-
ctx.fail(
|
|
187
|
-
`${cmd.op}-invalid-resource`,
|
|
188
|
-
`${cmd.op} 需要非空 resource 字符串`,
|
|
189
|
-
);
|
|
190
|
-
return false;
|
|
191
|
-
}
|
|
192
|
-
const volume = audioVolume(ctx, cmd.volume, cmd.op);
|
|
193
|
-
if (volume === null) return false;
|
|
194
|
-
// skipable:cutscene 显式参数 > video_skipable 持久开关(缺省可跳)
|
|
195
|
-
const skipable =
|
|
196
|
-
cutscene && cmd.skipable !== undefined
|
|
197
|
-
? cmd.skipable === true
|
|
198
|
-
: ctx.get(SYS.videoSkipable) !== false;
|
|
199
|
-
ctx.setSystem(SYS.video, {
|
|
200
|
-
kind: "play",
|
|
201
|
-
resource: cmd.resource,
|
|
202
|
-
volume,
|
|
203
|
-
loop: cmd.loop === true,
|
|
204
|
-
cutscene,
|
|
205
|
-
skipable,
|
|
206
|
-
seq,
|
|
207
|
-
} satisfies VideoCommand);
|
|
208
|
-
if (cutscene) {
|
|
209
|
-
// 过场等待建立时提交检查点(同 menu/wait/input);重放期同坐标原位替换
|
|
210
|
-
ctx.setSystem(SYS.waiting, "video");
|
|
211
|
-
ctx.commitCheckpoint(ctx.takeSnapshot(ctx.checkpointCoord(frame)));
|
|
212
|
-
ctx.liveCheckpointed = true;
|
|
213
|
-
ctx.autoSaveAtCheckpoint(); // cutscene 等待画面建立 = auto_save 消费点
|
|
214
|
-
frame.index += 1;
|
|
215
|
-
}
|
|
216
|
-
return true;
|
|
217
|
-
}
|
|
218
|
-
if (cmd.op === "seek_video") {
|
|
219
|
-
const seconds = cmd.seconds;
|
|
220
|
-
if (
|
|
221
|
-
typeof seconds !== "number" ||
|
|
222
|
-
!Number.isFinite(seconds) ||
|
|
223
|
-
seconds < 0
|
|
224
|
-
) {
|
|
225
|
-
ctx.fail(
|
|
226
|
-
"seek_video-invalid-seconds",
|
|
227
|
-
"seek_video 需要非负有限数字(秒)",
|
|
228
|
-
);
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
ctx.setSystem(SYS.video, {
|
|
232
|
-
kind: "seek",
|
|
233
|
-
seconds,
|
|
234
|
-
seq,
|
|
235
|
-
} satisfies VideoCommand);
|
|
236
|
-
return true;
|
|
237
|
-
}
|
|
238
|
-
if (cmd.op === "pause_video") {
|
|
239
|
-
ctx.setSystem(SYS.video, { kind: "pause", seq } satisfies VideoCommand);
|
|
240
|
-
return true;
|
|
241
|
-
}
|
|
242
|
-
if (cmd.op === "resume_video") {
|
|
243
|
-
ctx.setSystem(SYS.video, { kind: "resume", seq } satisfies VideoCommand);
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
if (cmd.op === "stop_video") {
|
|
247
|
-
ctx.setSystem(SYS.video, { kind: "stop", seq } satisfies VideoCommand);
|
|
248
|
-
return true;
|
|
249
|
-
}
|
|
250
|
-
// video_skipable
|
|
251
|
-
ctx.setSystem(SYS.videoSkipable, cmd.value !== false);
|
|
252
|
-
return true;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** notify:出站 toast 事件(覆盖层);文本插值与 say 同语义 */
|
|
256
|
-
export function execNotify(ctx: OpContext, cmd: StoryCommand): void {
|
|
257
|
-
if (ctx.rejectBadInstanceZ(cmd)) return; // 先拒非法 z(不动状态)
|
|
258
|
-
const { text, errors } = interpolateText(
|
|
259
|
-
ctx.translate(cmd.text as string),
|
|
260
|
-
ctx.resolveName,
|
|
261
|
-
ctx.draw,
|
|
262
|
-
);
|
|
263
|
-
for (const e of errors)
|
|
264
|
-
ctx.fail(e.code, ctx.texts.interpolationFallback(e.message));
|
|
265
|
-
ctx.setInstanceZ(SYS.notificationsZ, cmd.z); // 本条通知的实例 z(notifications 层)
|
|
266
|
-
const payload: OutboundPayload = {
|
|
267
|
-
kind: "notify",
|
|
268
|
-
text,
|
|
269
|
-
...(typeof cmd.type === "string" ? { notifyType: cmd.type } : {}),
|
|
270
|
-
...(typeof cmd.duration === "number" ? { duration: cmd.duration } : {}),
|
|
271
|
-
};
|
|
272
|
-
const event: OutboundEvent = { v: 1, kind: "event", payload };
|
|
273
|
-
for (const listener of ctx.eventListeners) listener(event);
|
|
274
|
-
}
|
|
File without changes
|
|
File without changes
|