@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.
- 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 +41 -1
- package/src/contracts/runtime/state-keys.ts +70 -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/format/validate/dialog.ts +32 -3
- 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/dialog.ts +15 -3
- 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/literals.ts +140 -0
- package/src/data/text/parse/statement/dialog.ts +19 -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/interaction.ts +14 -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 +47 -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
|
@@ -4,7 +4,9 @@ import type { StoryCommand } from "../../../../contracts/index.ts";
|
|
|
4
4
|
import type { GenerateContext } from "../context.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* 投影本族的 op 行(`bgm` `se` `ambient` `voice` `stop_bgm` `stop_ambient` `stop_voice` `
|
|
7
|
+
* 投影本族的 op 行(`bgm` `se` `ambient` `voice` `stop_bgm` `stop_ambient` `stop_voice` `stop_se`
|
|
8
|
+
* `audio_play` `audio_stop` `audio_volume` `video` `cutscene` `seek_video` `pause_video`
|
|
9
|
+
* `resume_video` `stop_video` `video_skipable`)。
|
|
8
10
|
*
|
|
9
11
|
* 入参是分发骨架建好的 `GenerateContext`:`pad` 是本行缩进前缀(块体逐层加深),
|
|
10
12
|
* `out` 是输出缓冲,投影出的行直接推入,不留空行。
|
|
@@ -33,12 +35,32 @@ export function generateMediaCommand(
|
|
|
33
35
|
}
|
|
34
36
|
case "stop_bgm":
|
|
35
37
|
case "stop_ambient":
|
|
36
|
-
case "stop_voice":
|
|
38
|
+
case "stop_voice":
|
|
39
|
+
case "stop_se": {
|
|
37
40
|
let line = `${pad}${cmd.op}`;
|
|
38
41
|
if (cmd.fade !== undefined) line += ` fade=${cmd.fade}`;
|
|
39
42
|
out.push(line);
|
|
40
43
|
return;
|
|
41
44
|
}
|
|
45
|
+
case "audio_play": {
|
|
46
|
+
let line = `${pad}audio_play ${cmd.inst as string} ${cmd.channel as string} ${quoteForText(cmd.resource as string)}`;
|
|
47
|
+
if (cmd.volume !== undefined) line += ` volume=${cmd.volume}`;
|
|
48
|
+
if (cmd.loop !== undefined) line += ` loop=${cmd.loop}`;
|
|
49
|
+
if (cmd.fade !== undefined) line += ` fade=${cmd.fade}`;
|
|
50
|
+
if (cmd.restart !== undefined) line += ` restart=${cmd.restart}`;
|
|
51
|
+
out.push(line);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
case "audio_stop": {
|
|
55
|
+
let line = `${pad}audio_stop`;
|
|
56
|
+
if (cmd.inst !== undefined) line += ` ${cmd.inst as string}`;
|
|
57
|
+
if (cmd.fade !== undefined) line += ` fade=${cmd.fade}`;
|
|
58
|
+
out.push(line);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
case "audio_volume":
|
|
62
|
+
out.push(`${pad}audio_volume ${cmd.inst as string} volume=${cmd.volume}`);
|
|
63
|
+
return;
|
|
42
64
|
case "video":
|
|
43
65
|
case "cutscene": {
|
|
44
66
|
let line = `${pad}${cmd.op} ${quoteForText(cmd.resource as string)}`;
|
|
@@ -61,6 +83,15 @@ export function generateMediaCommand(
|
|
|
61
83
|
case "stop_video":
|
|
62
84
|
out.push(`${pad}stop_video`);
|
|
63
85
|
return;
|
|
86
|
+
case "audio_pause":
|
|
87
|
+
case "audio_resume":
|
|
88
|
+
// channel 省略即「全部常驻通道」,不能补默认值——写出来会变成只暂停某一个
|
|
89
|
+
out.push(
|
|
90
|
+
cmd.channel === undefined
|
|
91
|
+
? `${pad}${cmd.op}`
|
|
92
|
+
: `${pad}${cmd.op} ${cmd.channel as string}`,
|
|
93
|
+
);
|
|
94
|
+
return;
|
|
64
95
|
case "video_skipable":
|
|
65
96
|
out.push(
|
|
66
97
|
`${pad}video_skipable ${cmd.value === false ? "false" : "true"}`,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** ui 族的文本投影。 */
|
|
2
|
+
import { generateDictLiteral } from "../../literals.ts";
|
|
3
|
+
import type { StoryCommand } from "../../../../contracts/index.ts";
|
|
4
|
+
import type { GenerateContext } from "../context.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 投影本族的 op 行(`mount`)。
|
|
8
|
+
*
|
|
9
|
+
* 入参是分发骨架建好的 `GenerateContext`:`pad` 是本行缩进前缀(块体逐层加深),
|
|
10
|
+
* `out` 是输出缓冲,投影出的行直接推入,不留空行。
|
|
11
|
+
* 无返回值,结果通过 `out` 交付。
|
|
12
|
+
* 失败表现:字段无法投影(`props` 不是对象等)时抛 `TextFormatError`,不是静默跳过——
|
|
13
|
+
* 由上层按命令粒度捕获后降级为 issue,使整棵树的其余部分照常输出。
|
|
14
|
+
*
|
|
15
|
+
* 形态:`mount <UI 名> <true|false> [props={…}]`。UI 名是短标识(不含引号也不含空白),
|
|
16
|
+
* 显隐写成字面 `true` / `false`——不写「切换」,文本形态与状态语义一样确定。
|
|
17
|
+
*/
|
|
18
|
+
export function generateUiCommand(cmd: StoryCommand, c: GenerateContext): void {
|
|
19
|
+
const { pad, out } = c;
|
|
20
|
+
switch (cmd.op) {
|
|
21
|
+
case "mount": {
|
|
22
|
+
let line = `${pad}mount ${cmd.name as string} ${cmd.visible === true ? "true" : "false"}`;
|
|
23
|
+
if (cmd.props !== undefined)
|
|
24
|
+
line += ` props=${generateDictLiteral(cmd.props as Record<string, unknown>)}`;
|
|
25
|
+
out.push(line);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
generateMediaCommand,
|
|
14
14
|
generatePresentCommand,
|
|
15
15
|
generateSaveCommand,
|
|
16
|
+
generateUiCommand,
|
|
16
17
|
generateVariablesCommand,
|
|
17
18
|
} from "./command/index.ts";
|
|
18
19
|
import { tryProjectToText } from "./projection.ts";
|
|
@@ -88,6 +89,12 @@ export function generateCommand(
|
|
|
88
89
|
case "stop_bgm":
|
|
89
90
|
case "stop_ambient":
|
|
90
91
|
case "stop_voice":
|
|
92
|
+
case "stop_se":
|
|
93
|
+
case "audio_play":
|
|
94
|
+
case "audio_stop":
|
|
95
|
+
case "audio_volume":
|
|
96
|
+
case "audio_pause":
|
|
97
|
+
case "audio_resume":
|
|
91
98
|
case "video":
|
|
92
99
|
case "cutscene":
|
|
93
100
|
case "seek_video":
|
|
@@ -120,6 +127,9 @@ export function generateCommand(
|
|
|
120
127
|
case "text_typewriter":
|
|
121
128
|
generatePresentCommand(cmd, ctx);
|
|
122
129
|
return;
|
|
130
|
+
case "mount":
|
|
131
|
+
generateUiCommand(cmd, ctx);
|
|
132
|
+
return;
|
|
123
133
|
default: {
|
|
124
134
|
// 扩展投影:声明了 toText 的自定义 op 交给投影器;否则 fail-closed(不静默)
|
|
125
135
|
const toText = projections?.get(cmd.op)?.toText;
|
|
@@ -110,6 +110,146 @@ export function extractKeyedDictLiteral(
|
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* 提取 `key=[…]` 形式的数组字面量(方括号计数,引号内的括号不算)。
|
|
115
|
+
*
|
|
116
|
+
* 与 `extractKeyedDictLiteral` 同法,只是括号类型不同;`key` 只认**字符串外**且前面是空白
|
|
117
|
+
* 或行首的那一处(避免提示语里恰好出现 `options=` 被误当参数)。
|
|
118
|
+
* 返回字面量与剥离后的余文;找不到或不闭合返回 `null`。
|
|
119
|
+
*/
|
|
120
|
+
export function extractKeyedArrayLiteral(
|
|
121
|
+
source: string,
|
|
122
|
+
key: string,
|
|
123
|
+
): { literal: string; remainder: string } | null {
|
|
124
|
+
const at = findKeyOutsideString(source, key);
|
|
125
|
+
if (at < 0) return null;
|
|
126
|
+
const bracketAt = source.indexOf("[", at + key.length + 1);
|
|
127
|
+
if (bracketAt < 0) return null;
|
|
128
|
+
let depth = 0;
|
|
129
|
+
let end = -1;
|
|
130
|
+
let inString = false;
|
|
131
|
+
for (let i = bracketAt; i < source.length; i += 1) {
|
|
132
|
+
const ch = source[i]!;
|
|
133
|
+
if (inString) {
|
|
134
|
+
if (ch === "\\") {
|
|
135
|
+
i += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ch === '"') inString = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (ch === '"') {
|
|
142
|
+
inString = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (ch === "[") depth += 1;
|
|
146
|
+
else if (ch === "]") {
|
|
147
|
+
depth -= 1;
|
|
148
|
+
if (depth === 0) {
|
|
149
|
+
end = i;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (end < 0) return null;
|
|
155
|
+
return {
|
|
156
|
+
literal: source.slice(bracketAt, end + 1),
|
|
157
|
+
remainder: `${source.slice(0, at)} ${source.slice(end + 1)}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 找 `key=` 在字符串外、且位于行首或空白之后的位置;找不到返回 -1 */
|
|
162
|
+
function findKeyOutsideString(source: string, key: string): number {
|
|
163
|
+
let inString = false;
|
|
164
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
165
|
+
const ch = source[i]!;
|
|
166
|
+
if (inString) {
|
|
167
|
+
if (ch === "\\") {
|
|
168
|
+
i += 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (ch === '"') inString = false;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (ch === '"') {
|
|
175
|
+
inString = true;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
source.startsWith(`${key}=`, i) &&
|
|
180
|
+
(i === 0 || /\s/.test(source[i - 1]!))
|
|
181
|
+
) {
|
|
182
|
+
return i;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return -1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `[{"text": …, "value": …}, …]` 数组字面量 → 输入候选表。
|
|
190
|
+
*
|
|
191
|
+
* 与 `generateInputOptions` 严格互逆(往返字节级一致);字段不是字符串的项原样保留为
|
|
192
|
+
* `undefined` 之外的空串,交由校验层报错——解析器不替作者猜。
|
|
193
|
+
*/
|
|
194
|
+
export function parseInputOptionsLiteral(
|
|
195
|
+
raw: string,
|
|
196
|
+
): Array<{ text: string; value: string }> {
|
|
197
|
+
const inner = raw.trim().replace(/^\[/, "").replace(/\]$/, "");
|
|
198
|
+
const parts: string[] = [];
|
|
199
|
+
let cur = "";
|
|
200
|
+
let inString = false;
|
|
201
|
+
let depth = 0;
|
|
202
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
203
|
+
const ch = inner[i]!;
|
|
204
|
+
if (inString) {
|
|
205
|
+
cur += ch;
|
|
206
|
+
if (ch === "\\") {
|
|
207
|
+
cur += inner[i + 1] ?? "";
|
|
208
|
+
i += 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (ch === '"') inString = false;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (ch === '"') {
|
|
215
|
+
inString = true;
|
|
216
|
+
cur += ch;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (ch === "{") depth += 1;
|
|
220
|
+
if (ch === "}") depth -= 1;
|
|
221
|
+
if (ch === "," && depth === 0) {
|
|
222
|
+
if (cur.trim() !== "") parts.push(cur);
|
|
223
|
+
cur = "";
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
cur += ch;
|
|
227
|
+
}
|
|
228
|
+
if (cur.trim() !== "") parts.push(cur);
|
|
229
|
+
return parts.map((part) => {
|
|
230
|
+
const dict = parseDictLiteral(part);
|
|
231
|
+
return {
|
|
232
|
+
text: typeof dict.text === "string" ? dict.text : "",
|
|
233
|
+
value: typeof dict.value === "string" ? dict.value : "",
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 输入候选表的文本投影:`[{"text": "…", "value": "…"}, …]`。
|
|
240
|
+
*
|
|
241
|
+
* 与 `parseInputOptionsLiteral` 严格互逆;每项经 `generateDictLiteral`(键序 text → value,
|
|
242
|
+
* 插入序确定,故同一份数据两次投影字节相同)。
|
|
243
|
+
*/
|
|
244
|
+
export function generateInputOptions(
|
|
245
|
+
options: readonly { text: string; value: string }[],
|
|
246
|
+
): string {
|
|
247
|
+
const items = options.map((item) =>
|
|
248
|
+
generateDictLiteral({ text: item.text, value: item.value }),
|
|
249
|
+
);
|
|
250
|
+
return `[${items.join(", ")}]`;
|
|
251
|
+
}
|
|
252
|
+
|
|
113
253
|
/** {"f":v} 字典字面量 → 对象(字段值走 parseValueLiteral 语义) */
|
|
114
254
|
export function parseDictLiteral(raw: string): Record<string, unknown> {
|
|
115
255
|
const inner = raw.trim().replace(/^\{/, "").replace(/\}$/, "");
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/** dialog 族的读向解析。 */
|
|
2
2
|
import { parseSay } from "../say.ts";
|
|
3
3
|
import { unquote } from "../../lexer.ts";
|
|
4
|
+
import {
|
|
5
|
+
extractKeyedArrayLiteral,
|
|
6
|
+
parseInputOptionsLiteral,
|
|
7
|
+
} from "../../literals.ts";
|
|
4
8
|
import type { StoryCommand } from "../../../../contracts/index.ts";
|
|
5
9
|
import type { StatementContext } from "../state.ts";
|
|
6
10
|
|
|
@@ -89,6 +93,21 @@ export function parseDialogStatement(c: StatementContext): StoryCommand | null {
|
|
|
89
93
|
prompt: unquote(prompt),
|
|
90
94
|
store: unquote(storeEq.slice(6)),
|
|
91
95
|
};
|
|
96
|
+
// 候选快捷值:`input "提示" store="k" options=[{"text": "…", "value": "…"}]`
|
|
97
|
+
// 从整行里摘(不能只搜 token——数组内部有空格,会被切词拆散)
|
|
98
|
+
const optionsLiteral = extractKeyedArrayLiteral(rest, "options");
|
|
99
|
+
if (optionsLiteral !== null) {
|
|
100
|
+
const candidates = parseInputOptionsLiteral(optionsLiteral.literal);
|
|
101
|
+
// 缺字段的项在字面量层会落成空串;这里补一条带定位的 issue,
|
|
102
|
+
// 不让它悄悄变成「点了候选写入空值」(与 JSON 侧的 requireNonEmptyString 同口径)
|
|
103
|
+
const broken = candidates.findIndex(
|
|
104
|
+
(item) => item.text === "" || item.value === "",
|
|
105
|
+
);
|
|
106
|
+
if (broken >= 0) {
|
|
107
|
+
return fail(`input 候选 options[${broken}] 需要 text 与 value`);
|
|
108
|
+
}
|
|
109
|
+
cmd.options = candidates;
|
|
110
|
+
}
|
|
92
111
|
const zEq = tokens.find(
|
|
93
112
|
(t) => t.startsWith("z=") || t.startsWith("z-index="),
|
|
94
113
|
);
|
|
@@ -113,7 +113,7 @@ export function parseFlowStatement(c: StatementContext): StoryCommand | null {
|
|
|
113
113
|
// ——若一律落到「暂不支持的语句」,整个工程就打不开。
|
|
114
114
|
//
|
|
115
115
|
// 语义对齐:`scene "x"`(跳转)≡ `navigate "x"`(坐标切换命令)。
|
|
116
|
-
// 区别于**列声明**的 `scene "名" type=
|
|
116
|
+
// 区别于**列声明**的 `scene "名" type=ui`(那个在顶层解析,见 `parseTextStory`)。
|
|
117
117
|
// `tokens[0]` 是 op 本身,目标名在 `tokens[1]`(`splitTokens` 的口径)
|
|
118
118
|
const target = quoted(0);
|
|
119
119
|
if (target === "") return fail("scene 需要目标场景名");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 语句解析族出口:按命令族分片的语句解析器。
|
|
3
3
|
*
|
|
4
|
-
* 轴 = 命令族(集合 / 对话 / 流程 / 媒体 / 表演 / 存档 / 变量),每片只认自己那族的关键字。
|
|
4
|
+
* 轴 = 命令族(集合 / 对话 / 流程 / 媒体 / 表演 / 存档 / UI / 变量),每片只认自己那族的关键字。
|
|
5
5
|
*/
|
|
6
6
|
export { parseCollectionsStatement } from "./collections.ts";
|
|
7
7
|
export { parseDialogStatement } from "./dialog.ts";
|
|
@@ -9,4 +9,5 @@ export { parseFlowStatement } from "./flow.ts";
|
|
|
9
9
|
export { parseMediaStatement } from "./media.ts";
|
|
10
10
|
export { parsePresentStatement } from "./present.ts";
|
|
11
11
|
export { parseSaveStatement } from "./save.ts";
|
|
12
|
+
export { parseUiStatement } from "./ui.ts";
|
|
12
13
|
export { parseVariablesStatement } from "./variables.ts";
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/** media 族的读向解析。 */
|
|
2
2
|
import { unquote } from "../../lexer.ts";
|
|
3
|
+
import { isAudioChannel } from "../../../../shared/index.ts";
|
|
3
4
|
import type { StoryCommand } from "../../../../contracts/index.ts";
|
|
4
5
|
import type { StatementContext } from "../state.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* 解析本族的 op 行(`bgm` `se` `ambient` `voice` `stop_bgm` `stop_ambient` `stop_voice` `
|
|
8
|
+
* 解析本族的 op 行(`bgm` `se` `ambient` `voice` `stop_bgm` `stop_ambient` `stop_voice` `stop_se`
|
|
9
|
+
* `audio_play` `audio_stop` `audio_volume` `audio_pause` `audio_resume` `video` `cutscene`
|
|
10
|
+
* `seek_video` `pause_video` `resume_video` `stop_video` `video_skipable`)。
|
|
8
11
|
*
|
|
9
12
|
* 入参是分发骨架建好的 `StatementContext`(已切好的词表、定位前缀、issue 出口与警告通道)。
|
|
10
13
|
* 只处理本族的 op,不匹配时落到末尾 `return null`,由骨架决定后续。
|
|
@@ -51,7 +54,8 @@ export function parseMediaStatement(c: StatementContext): StoryCommand | null {
|
|
|
51
54
|
}
|
|
52
55
|
case "stop_bgm":
|
|
53
56
|
case "stop_ambient":
|
|
54
|
-
case "stop_voice":
|
|
57
|
+
case "stop_voice":
|
|
58
|
+
case "stop_se": {
|
|
55
59
|
const cmd: StoryCommand = { op };
|
|
56
60
|
for (const token of tokens) {
|
|
57
61
|
const eq = token.indexOf("=");
|
|
@@ -65,6 +69,79 @@ export function parseMediaStatement(c: StatementContext): StoryCommand | null {
|
|
|
65
69
|
}
|
|
66
70
|
return cmd;
|
|
67
71
|
}
|
|
72
|
+
// 音频实例族:位置参数是 id(与 resource 的引号形态区分开,作者一眼能看出哪个是名字)
|
|
73
|
+
case "audio_play": {
|
|
74
|
+
const inst = tokens[0];
|
|
75
|
+
const channel = tokens[1];
|
|
76
|
+
const resource = tokens.find((t) => t.startsWith('"'));
|
|
77
|
+
if (inst === undefined) return fail("audio_play 需要实例 id");
|
|
78
|
+
if (channel === undefined) return fail("audio_play 需要 channel");
|
|
79
|
+
if (resource === undefined) return fail("audio_play 需要 resource");
|
|
80
|
+
const cmd: StoryCommand = {
|
|
81
|
+
op: "audio_play",
|
|
82
|
+
inst,
|
|
83
|
+
channel,
|
|
84
|
+
resource: unquote(resource),
|
|
85
|
+
};
|
|
86
|
+
for (const token of tokens) {
|
|
87
|
+
if (token === inst || token === channel || token.startsWith('"'))
|
|
88
|
+
continue;
|
|
89
|
+
const eq = token.indexOf("=");
|
|
90
|
+
const key = eq < 0 ? token : token.slice(0, eq);
|
|
91
|
+
const raw = eq < 0 ? "" : token.slice(eq + 1);
|
|
92
|
+
if (key === "volume" || key === "fade") {
|
|
93
|
+
const num = Number(raw);
|
|
94
|
+
if (eq < 0 || raw === "" || Number.isNaN(num))
|
|
95
|
+
return fail(`audio_play 的 ${key} 需要数字`);
|
|
96
|
+
cmd[key] = num;
|
|
97
|
+
} else if (key === "loop" || key === "restart") {
|
|
98
|
+
if (raw !== "true" && raw !== "false")
|
|
99
|
+
return fail(`audio_play 的 ${key} 需要 true|false`);
|
|
100
|
+
cmd[key] = raw === "true";
|
|
101
|
+
} else {
|
|
102
|
+
return fail(`audio_play 未知参数:${token}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return cmd;
|
|
106
|
+
}
|
|
107
|
+
case "audio_stop": {
|
|
108
|
+
const cmd: StoryCommand = { op: "audio_stop" };
|
|
109
|
+
for (const token of tokens) {
|
|
110
|
+
const eq = token.indexOf("=");
|
|
111
|
+
if (eq < 0) {
|
|
112
|
+
// 唯一的无等号位置参数是目标实例;重复给就是写错
|
|
113
|
+
if (cmd.inst !== undefined)
|
|
114
|
+
return fail(`audio_stop 只有一个实例位置参数:${token}`);
|
|
115
|
+
cmd.inst = token;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const key = token.slice(0, eq);
|
|
119
|
+
const raw = token.slice(eq + 1);
|
|
120
|
+
if (key !== "fade") return fail(`audio_stop 未知参数:${token}`);
|
|
121
|
+
const num = Number(raw);
|
|
122
|
+
if (raw === "" || Number.isNaN(num))
|
|
123
|
+
return fail("audio_stop 的 fade 需要数字");
|
|
124
|
+
cmd.fade = num;
|
|
125
|
+
}
|
|
126
|
+
return cmd;
|
|
127
|
+
}
|
|
128
|
+
case "audio_volume": {
|
|
129
|
+
const inst = tokens[0];
|
|
130
|
+
let volume: number | undefined;
|
|
131
|
+
for (const token of tokens.slice(1)) {
|
|
132
|
+
const eq = token.indexOf("=");
|
|
133
|
+
const key = eq < 0 ? token : token.slice(0, eq);
|
|
134
|
+
const raw = eq < 0 ? "" : token.slice(eq + 1);
|
|
135
|
+
if (key !== "volume") return fail(`audio_volume 未知参数:${token}`);
|
|
136
|
+
const num = Number(raw);
|
|
137
|
+
if (eq < 0 || raw === "" || Number.isNaN(num))
|
|
138
|
+
return fail("audio_volume 的 volume 需要数字");
|
|
139
|
+
volume = num;
|
|
140
|
+
}
|
|
141
|
+
if (inst === undefined) return fail("audio_volume 需要实例 id");
|
|
142
|
+
if (volume === undefined) return fail("audio_volume 需要 volume");
|
|
143
|
+
return { op: "audio_volume", inst, volume };
|
|
144
|
+
}
|
|
68
145
|
// 视频族:resource 位置参数(video/cutscene);seek_video 为秒数
|
|
69
146
|
case "video":
|
|
70
147
|
case "cutscene": {
|
|
@@ -102,6 +179,18 @@ export function parseMediaStatement(c: StatementContext): StoryCommand | null {
|
|
|
102
179
|
}
|
|
103
180
|
case "pause_video":
|
|
104
181
|
return { op: "pause_video" } as StoryCommand;
|
|
182
|
+
case "audio_pause":
|
|
183
|
+
case "audio_resume": {
|
|
184
|
+
// 位置参数可选:不写 channel = 全部常驻通道(se 除外),由执行期展开
|
|
185
|
+
const raw = tokens[0];
|
|
186
|
+
if (tokens.length > 1) return fail(`${op} 只接受一个 channel 参数`);
|
|
187
|
+
if (raw === undefined) return { op } as StoryCommand;
|
|
188
|
+
if (!isAudioChannel(raw))
|
|
189
|
+
return fail(
|
|
190
|
+
`${op} 的 channel 不合法:${raw}(须为 bgm / se / ambient / voice 之一)`,
|
|
191
|
+
);
|
|
192
|
+
return { op, channel: raw } as StoryCommand;
|
|
193
|
+
}
|
|
105
194
|
case "resume_video":
|
|
106
195
|
return { op: "resume_video" } as StoryCommand;
|
|
107
196
|
case "stop_video":
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** ui 族的读向解析。 */
|
|
2
|
+
import { parseDictLiteral, extractKeyedDictLiteral } from "../../literals.ts";
|
|
3
|
+
import { splitTokens } from "../../lexer.ts";
|
|
4
|
+
import type { StoryCommand } from "../../../../contracts/index.ts";
|
|
5
|
+
import type { StatementContext } from "../state.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 解析本族的 op 行(`mount`)。
|
|
9
|
+
*
|
|
10
|
+
* 入参是分发骨架建好的 `StatementContext`(已切好的词表、定位前缀、issue 出口与警告通道)。
|
|
11
|
+
* 只处理本族的 op,不匹配时落到末尾 `return null`,由骨架决定后续。
|
|
12
|
+
* 失败表现:不抛异常;语法或参数不符时用 `c.fail` 记一条带定位的 issue 并返回 `null`,
|
|
13
|
+
* 由调用方按 issues 非空整次拒绝。
|
|
14
|
+
*
|
|
15
|
+
* 形态:`mount <UI 名> <true|false> [props={…}]`。UI 名不带引号(短标识,与 `id=`/`name=`
|
|
16
|
+
* 等位置参同形);显隐只认字面 `true` / `false`——没有「切换」写法,避免文本形态与状态语义
|
|
17
|
+
* 各说一套。
|
|
18
|
+
*/
|
|
19
|
+
export function parseUiStatement(c: StatementContext): StoryCommand | null {
|
|
20
|
+
const { op, rest, tokens, fail } = c;
|
|
21
|
+
switch (op) {
|
|
22
|
+
case "mount": {
|
|
23
|
+
const name = tokens[0];
|
|
24
|
+
if (name === undefined) return fail("mount 需要 UI 名");
|
|
25
|
+
const visible = tokens[1];
|
|
26
|
+
if (visible !== "true" && visible !== "false")
|
|
27
|
+
return fail("mount 需要 true|false(显隐必填,没有切换写法)");
|
|
28
|
+
const cmd: StoryCommand = {
|
|
29
|
+
op: "mount",
|
|
30
|
+
name,
|
|
31
|
+
visible: visible === "true",
|
|
32
|
+
};
|
|
33
|
+
const dict = extractKeyedDictLiteral(rest, "props");
|
|
34
|
+
if (dict !== null) {
|
|
35
|
+
const extra = splitTokens(dict.remainder).filter(
|
|
36
|
+
(t) => t !== name && t !== visible,
|
|
37
|
+
);
|
|
38
|
+
if (extra.length > 0) return fail(`mount 未知参数:${extra[0]}`);
|
|
39
|
+
cmd.props = parseDictLiteral(dict.literal);
|
|
40
|
+
return cmd;
|
|
41
|
+
}
|
|
42
|
+
if (tokens.length > 2) return fail(`mount 未知参数:${tokens[2]}`);
|
|
43
|
+
return cmd;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
parseMediaStatement,
|
|
13
13
|
parsePresentStatement,
|
|
14
14
|
parseSaveStatement,
|
|
15
|
+
parseUiStatement,
|
|
15
16
|
parseVariablesStatement,
|
|
16
17
|
} from "./statement/index.ts";
|
|
17
18
|
import { unquote } from "../lexer.ts";
|
|
@@ -97,6 +98,12 @@ export function parseSimpleStatement(
|
|
|
97
98
|
case "stop_bgm":
|
|
98
99
|
case "stop_ambient":
|
|
99
100
|
case "stop_voice":
|
|
101
|
+
case "stop_se":
|
|
102
|
+
case "audio_play":
|
|
103
|
+
case "audio_stop":
|
|
104
|
+
case "audio_volume":
|
|
105
|
+
case "audio_pause":
|
|
106
|
+
case "audio_resume":
|
|
100
107
|
case "video":
|
|
101
108
|
case "cutscene":
|
|
102
109
|
case "seek_video":
|
|
@@ -118,6 +125,8 @@ export function parseSimpleStatement(
|
|
|
118
125
|
case "shake":
|
|
119
126
|
case "text_typewriter":
|
|
120
127
|
return parsePresentStatement(ctx);
|
|
128
|
+
case "mount":
|
|
129
|
+
return parseUiStatement(ctx);
|
|
121
130
|
default:
|
|
122
131
|
return fail(`文本投影暂不支持的语句:${op}`);
|
|
123
132
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 内部按功能域归类,每个域有自己的出口,包出口只转发这些出口:
|
|
5
5
|
* `contracts/`(契约,全量转发)、`runtime/index.ts`(执行器与运行期支撑)、
|
|
6
6
|
* `data/index.ts`(故事文件解析·工程组装·文本投影·元素装载)。
|
|
7
|
-
* 包出口不逐个深入实现文件;域内零件(`runtime/scope`、`runtime/resolver` 等)不经这里暴露。
|
|
7
|
+
* 包出口不逐个深入实现文件;域内零件(`runtime/scope`、`runtime/expr/resolver.ts` 等)不经这里暴露。
|
|
8
8
|
*/
|
|
9
9
|
export * from "./contracts/index.ts";
|
|
10
10
|
/**
|
|
@@ -35,7 +35,9 @@ export {
|
|
|
35
35
|
collectGuardNames,
|
|
36
36
|
collectTextProjections,
|
|
37
37
|
collectWaitSpecs,
|
|
38
|
+
columnSummaries,
|
|
38
39
|
createStateReader,
|
|
40
|
+
DEFAULT_AUDIO_CONFIG,
|
|
39
41
|
DEFAULT_LAYER_Z,
|
|
40
42
|
DEFAULT_SAVES_CONFIG,
|
|
41
43
|
DEFAULT_STAGE_CONFIG,
|
|
@@ -53,10 +55,12 @@ export {
|
|
|
53
55
|
manifestOrientation,
|
|
54
56
|
mergeOverlayFiles,
|
|
55
57
|
PlayerPreferences,
|
|
58
|
+
resolveAudioConfig,
|
|
56
59
|
resolveHost,
|
|
57
60
|
resolveInstanceZ,
|
|
58
61
|
resolveLayerZ,
|
|
59
62
|
resolveOrientationMode,
|
|
63
|
+
resolveProjectName,
|
|
60
64
|
resolveSavesConfig,
|
|
61
65
|
resolveStageConfig,
|
|
62
66
|
slotIds,
|
|
@@ -64,6 +68,8 @@ export {
|
|
|
64
68
|
WAITING_OPS,
|
|
65
69
|
waitSpecOfOp,
|
|
66
70
|
waitingStateOfOp,
|
|
71
|
+
directionOf,
|
|
72
|
+
type AudioConfig,
|
|
67
73
|
type EngineOptions,
|
|
68
74
|
type ExtensionModuleLoader,
|
|
69
75
|
type GuardContext,
|
|
@@ -112,6 +118,7 @@ export {
|
|
|
112
118
|
ProjectSerializationError,
|
|
113
119
|
projectText,
|
|
114
120
|
removeElements,
|
|
121
|
+
RESOURCES_DIR,
|
|
115
122
|
serializeColumnDocument,
|
|
116
123
|
serializeProject,
|
|
117
124
|
STORIES_DIR,
|
package/src/runtime/dispatch.ts
CHANGED
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
execAssert,
|
|
14
14
|
execAssign,
|
|
15
15
|
execAudio,
|
|
16
|
+
execAudioInstance,
|
|
17
|
+
execAudioPause,
|
|
18
|
+
execAudioResume,
|
|
16
19
|
execAutoSaveOp,
|
|
17
20
|
execCall,
|
|
18
21
|
execCharacter,
|
|
@@ -31,6 +34,7 @@ import {
|
|
|
31
34
|
execLoadOp,
|
|
32
35
|
execMenu,
|
|
33
36
|
execMinigame,
|
|
37
|
+
execMount,
|
|
34
38
|
execNavigate,
|
|
35
39
|
execNotify,
|
|
36
40
|
execNvl,
|
|
@@ -253,9 +257,24 @@ export function runDispatch(ctx: OpContext): void {
|
|
|
253
257
|
case "stop_ambient":
|
|
254
258
|
case "voice":
|
|
255
259
|
case "stop_voice":
|
|
260
|
+
case "stop_se":
|
|
256
261
|
if (!execAudio(ctx, cmd)) return;
|
|
257
262
|
frame.index += 1;
|
|
258
263
|
continue;
|
|
264
|
+
case "audio_play":
|
|
265
|
+
case "audio_stop":
|
|
266
|
+
case "audio_volume":
|
|
267
|
+
if (!execAudioInstance(ctx, cmd)) return;
|
|
268
|
+
frame.index += 1;
|
|
269
|
+
continue;
|
|
270
|
+
case "audio_pause":
|
|
271
|
+
if (!execAudioPause(ctx, cmd)) return;
|
|
272
|
+
frame.index += 1;
|
|
273
|
+
continue;
|
|
274
|
+
case "audio_resume":
|
|
275
|
+
if (!execAudioResume(ctx, cmd)) return;
|
|
276
|
+
frame.index += 1;
|
|
277
|
+
continue;
|
|
259
278
|
case "video":
|
|
260
279
|
case "seek_video":
|
|
261
280
|
case "pause_video":
|
|
@@ -306,6 +325,11 @@ export function runDispatch(ctx: OpContext): void {
|
|
|
306
325
|
if (!execTextTypewriter(ctx, cmd)) return;
|
|
307
326
|
frame.index += 1;
|
|
308
327
|
continue;
|
|
328
|
+
case "mount":
|
|
329
|
+
// 挂载 UI 显隐:写 SYS.mounts(宿主据此挂载/卸载渲染树上的组件)
|
|
330
|
+
if (!execMount(ctx, cmd)) return;
|
|
331
|
+
frame.index += 1;
|
|
332
|
+
continue;
|
|
309
333
|
default:
|
|
310
334
|
// fail-closed:未知/未实现 op 不静默跳过
|
|
311
335
|
ctx.fail("unknown-op", `未知或未实现的命令:${cmd.op}`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列清单投影:故事定义 → 宿主可读的只读摘要。
|
|
3
|
+
*
|
|
4
|
+
* **为什么要这一层而不是让宿主读 `story.columns`**:宿主一旦拿到整份 `StoryColumn`,
|
|
5
|
+
* 就能拿到 `commands` / `elements` / `entry`——那是引擎的解释输入。宿主自己解释一遍
|
|
6
|
+
* 命令流,等于在引擎之外再造一套解释器,两套必然漂移。这里只交出宿主真正用得上的
|
|
7
|
+
* 三件事:列 id、归一后的类型、内容形态。
|
|
8
|
+
*
|
|
9
|
+
* **写时复制**:每次调用都新建数组与对象并冻结。摘要一旦可写,宿主改一个 `type`
|
|
10
|
+
* 就会让「引擎认为的列类型」和「宿主认为的」分叉,而引擎那边毫不知情。
|
|
11
|
+
* 冻结把这种改动静默变成立即报错(严格模式下赋值抛 TypeError)。
|
|
12
|
+
*/
|
|
13
|
+
import { sceneTypeOf } from "../../shared/guards.ts";
|
|
14
|
+
import type { ColumnSummary } from "../../contracts/runtime/column-summary.ts";
|
|
15
|
+
import type { Story } from "../../contracts/story/entities.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 造一份列摘要表(顺序与 `story.columns` 一致)。
|
|
19
|
+
*
|
|
20
|
+
* 顺序即呈现顺序:宿主列章节时按这个次序,跳转与「下一列」也据此推断,
|
|
21
|
+
* 所以**不排序、不去重**——故事里的顺序就是作者写的顺序。
|
|
22
|
+
*/
|
|
23
|
+
export function columnSummaries(story: Story): readonly ColumnSummary[] {
|
|
24
|
+
return story.columns.map((column) =>
|
|
25
|
+
Object.freeze<ColumnSummary>({
|
|
26
|
+
id: column.id,
|
|
27
|
+
type: sceneTypeOf(column),
|
|
28
|
+
kind: column.kind,
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
}
|