@yagejs-addons/dialogue 0.1.0 → 0.2.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/dist/DialogueController-BMeNLi0v.d.cts +1204 -0
- package/dist/DialogueController-Cs5IUc-u.d.ts +1204 -0
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/chunk-CU47RPEB.js +410 -0
- package/dist/chunk-CU47RPEB.js.map +1 -0
- package/dist/chunk-GJQKZCOL.js +983 -0
- package/dist/chunk-GJQKZCOL.js.map +1 -0
- package/dist/index.cjs +3441 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +591 -0
- package/dist/index.d.ts +591 -0
- package/dist/index.js +2048 -0
- package/dist/index.js.map +1 -0
- package/dist/presenters.cjs +3149 -0
- package/dist/presenters.cjs.map +1 -0
- package/dist/presenters.d.cts +1817 -0
- package/dist/presenters.d.ts +1817 -0
- package/dist/presenters.js +2920 -0
- package/dist/presenters.js.map +1 -0
- package/dist/types-DSbBSlh7.d.cts +375 -0
- package/dist/types-DSbBSlh7.d.ts +375 -0
- package/dist/yaml.cjs +726 -0
- package/dist/yaml.cjs.map +1 -0
- package/dist/yaml.d.cts +23 -0
- package/dist/yaml.d.ts +23 -0
- package/dist/yaml.js +37 -0
- package/dist/yaml.js.map +1 -0
- package/package.json +4 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,2048 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EMPTY_PARSED,
|
|
3
|
+
LineReveal,
|
|
4
|
+
firstUnknownTag,
|
|
5
|
+
parseMarkup,
|
|
6
|
+
splitGraphemes,
|
|
7
|
+
stripMarkup
|
|
8
|
+
} from "./chunk-CU47RPEB.js";
|
|
9
|
+
import {
|
|
10
|
+
DialogueExprError,
|
|
11
|
+
DialoguePlayError,
|
|
12
|
+
DialogueScriptError,
|
|
13
|
+
IdentityI18n,
|
|
14
|
+
MemoryVariableStorage,
|
|
15
|
+
analyzeScript,
|
|
16
|
+
cells,
|
|
17
|
+
compose,
|
|
18
|
+
createScope,
|
|
19
|
+
evalCondition,
|
|
20
|
+
evaluate,
|
|
21
|
+
holds,
|
|
22
|
+
interpolate,
|
|
23
|
+
isExpr,
|
|
24
|
+
loadScript,
|
|
25
|
+
materialize,
|
|
26
|
+
parseExpr,
|
|
27
|
+
validatePlay
|
|
28
|
+
} from "./chunk-GJQKZCOL.js";
|
|
29
|
+
import {
|
|
30
|
+
__name
|
|
31
|
+
} from "./chunk-7QVYU63E.js";
|
|
32
|
+
|
|
33
|
+
// src/core/formats/compact.ts
|
|
34
|
+
function parseCompact(text) {
|
|
35
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
36
|
+
const speakers = {};
|
|
37
|
+
lines.forEach((raw, i) => {
|
|
38
|
+
const line = raw.trim();
|
|
39
|
+
if (!line.startsWith("@")) return;
|
|
40
|
+
const def = parseSpeaker(line, i + 1);
|
|
41
|
+
if (Object.hasOwn(speakers, def.id)) {
|
|
42
|
+
fail(i + 1, `duplicate speaker "${def.id}"`);
|
|
43
|
+
}
|
|
44
|
+
speakers[def.id] = def;
|
|
45
|
+
});
|
|
46
|
+
let id;
|
|
47
|
+
const nodes = {};
|
|
48
|
+
const nodeOrder = [];
|
|
49
|
+
let current = null;
|
|
50
|
+
let choiceRun = null;
|
|
51
|
+
const declares = {};
|
|
52
|
+
const flushChoice = /* @__PURE__ */ __name(() => {
|
|
53
|
+
if (choiceRun && current) current.steps.push({ kind: "choice", options: choiceRun });
|
|
54
|
+
choiceRun = null;
|
|
55
|
+
}, "flushChoice");
|
|
56
|
+
lines.forEach((raw, i) => {
|
|
57
|
+
const lineNo = i + 1;
|
|
58
|
+
const line = raw.trim();
|
|
59
|
+
if (line === "" || line.startsWith("//")) return;
|
|
60
|
+
if (line.startsWith("@")) return;
|
|
61
|
+
if (line.startsWith("#")) {
|
|
62
|
+
flushChoice();
|
|
63
|
+
const newId = line.slice(1).trim();
|
|
64
|
+
if (!newId) fail(lineNo, "'#' script id directive needs an id");
|
|
65
|
+
if (id !== void 0) fail(lineNo, `duplicate '#' script id directive (already "${id}")`);
|
|
66
|
+
id = newId;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (line.startsWith("::")) {
|
|
70
|
+
flushChoice();
|
|
71
|
+
const nodeId = line.slice(2).trim();
|
|
72
|
+
if (!nodeId) fail(lineNo, "':: ' node directive needs an id");
|
|
73
|
+
if (Object.hasOwn(nodes, nodeId)) fail(lineNo, `duplicate node "${nodeId}"`);
|
|
74
|
+
current = { id: nodeId, steps: [] };
|
|
75
|
+
nodes[nodeId] = current;
|
|
76
|
+
nodeOrder.push(nodeId);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (line.startsWith("?")) {
|
|
80
|
+
if (!current) fail(lineNo, "choice '?' appears before any ':: <node>'");
|
|
81
|
+
(choiceRun ??= []).push(parseChoice(line.slice(1).trim(), lineNo));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
flushChoice();
|
|
85
|
+
const decl = parseDeclare(line);
|
|
86
|
+
if (decl) {
|
|
87
|
+
declares[decl.name] = decl.value;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const node = current;
|
|
91
|
+
if (!node) fail(lineNo, `dialogue line appears before any ':: <node>' ("${line}")`);
|
|
92
|
+
if (line.startsWith("->")) {
|
|
93
|
+
node.steps.push(parseGoto(line, lineNo));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const set = parseSet(line);
|
|
97
|
+
if (set) {
|
|
98
|
+
node.steps.push(set);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const cmd = parseDo(line, lineNo);
|
|
102
|
+
if (cmd) {
|
|
103
|
+
node.steps.push(cmd);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (line === "end") {
|
|
107
|
+
node.steps.push({ kind: "end" });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
node.steps.push(parseSay(line, lineNo, speakers));
|
|
111
|
+
});
|
|
112
|
+
flushChoice();
|
|
113
|
+
if (id === void 0) throw new DialogueScriptError("compact: missing '# <id>' script directive");
|
|
114
|
+
if (nodeOrder.length === 0) {
|
|
115
|
+
throw new DialogueScriptError(`compact: script "${id}" has no ':: <node>' nodes`);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
start: nodeOrder[0],
|
|
120
|
+
nodes,
|
|
121
|
+
...Object.keys(speakers).length > 0 ? { speakers } : {},
|
|
122
|
+
...Object.keys(declares).length > 0 ? { declare: declares } : {}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
__name(parseCompact, "parseCompact");
|
|
126
|
+
function loadCompact(text) {
|
|
127
|
+
return loadScript(parseCompact(text));
|
|
128
|
+
}
|
|
129
|
+
__name(loadCompact, "loadCompact");
|
|
130
|
+
function parseSpeaker(line, lineNo) {
|
|
131
|
+
const tokens = line.slice(1).trim().split(/\s+/).filter(Boolean);
|
|
132
|
+
const id = tokens[0];
|
|
133
|
+
if (!id) fail(lineNo, "'@' speaker directive needs an id");
|
|
134
|
+
let nameTokens = tokens.slice(1);
|
|
135
|
+
let color;
|
|
136
|
+
const last = nameTokens[nameTokens.length - 1];
|
|
137
|
+
if (last !== void 0 && /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(last)) {
|
|
138
|
+
color = hexColor(last);
|
|
139
|
+
nameTokens = nameTokens.slice(0, -1);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
id,
|
|
143
|
+
name: nameTokens.length > 0 ? nameTokens.join(" ") : id,
|
|
144
|
+
...color !== void 0 ? { color } : {}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
__name(parseSpeaker, "parseSpeaker");
|
|
148
|
+
function hexColor(token) {
|
|
149
|
+
let hex = token.slice(1);
|
|
150
|
+
if (hex.length === 3) hex = hex.replace(/./g, "$&$&");
|
|
151
|
+
return parseInt(hex, 16);
|
|
152
|
+
}
|
|
153
|
+
__name(hexColor, "hexColor");
|
|
154
|
+
function parseGoto(line, lineNo) {
|
|
155
|
+
const m = /^->\s*(\S+)(?:\s+if:\s*(.+))?\s*$/.exec(line);
|
|
156
|
+
if (!m) fail(lineNo, "'->' goto needs a target node id (optionally `-> node if: cond`)");
|
|
157
|
+
const target = m[1];
|
|
158
|
+
if (m[2] !== void 0) {
|
|
159
|
+
return { kind: "command", commands: [], condition: parseExpr(m[2].trim()), target };
|
|
160
|
+
}
|
|
161
|
+
return { kind: "goto", target };
|
|
162
|
+
}
|
|
163
|
+
__name(parseGoto, "parseGoto");
|
|
164
|
+
function parseDeclare(line) {
|
|
165
|
+
const m = /^declare\s+([A-Za-z_$][A-Za-z0-9_.$]*)\s*=\s*(\S.*)$/.exec(line);
|
|
166
|
+
if (!m) return null;
|
|
167
|
+
return { name: m[1], value: scalar(m[2].trim()) };
|
|
168
|
+
}
|
|
169
|
+
__name(parseDeclare, "parseDeclare");
|
|
170
|
+
function parseSet(line) {
|
|
171
|
+
const m = /^set\s+([A-Za-z_$][A-Za-z0-9_.$]*)\s*=\s*(\S.*)$/.exec(line);
|
|
172
|
+
if (!m) return null;
|
|
173
|
+
return {
|
|
174
|
+
kind: "command",
|
|
175
|
+
commands: [{ type: "set", var: m[1], value: setValue(m[2].trim()) }]
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
__name(parseSet, "parseSet");
|
|
179
|
+
function setValue(rhs) {
|
|
180
|
+
const literal = numberBoolNull(rhs);
|
|
181
|
+
return literal === NOT_LITERAL ? parseExpr(rhs) : literal;
|
|
182
|
+
}
|
|
183
|
+
__name(setValue, "setValue");
|
|
184
|
+
function parseDo(line, lineNo) {
|
|
185
|
+
if (!/^do(\s|$)/.test(line)) return null;
|
|
186
|
+
const tokens = splitArgs(line.slice(2).trim());
|
|
187
|
+
const type = tokens[0];
|
|
188
|
+
if (type === void 0 || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(type)) return null;
|
|
189
|
+
const command = { type };
|
|
190
|
+
let typeKeyCollision = false;
|
|
191
|
+
for (const tok of tokens.slice(1)) {
|
|
192
|
+
if (tok.startsWith("#")) {
|
|
193
|
+
const flag = tok.slice(1);
|
|
194
|
+
if (!flag) return null;
|
|
195
|
+
if (flag === "type") typeKeyCollision = true;
|
|
196
|
+
else command[flag] = true;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const eq = tok.indexOf("=");
|
|
200
|
+
if (eq <= 0) return null;
|
|
201
|
+
const key = tok.slice(0, eq);
|
|
202
|
+
if (key === "type") typeKeyCollision = true;
|
|
203
|
+
else command[key] = scalar(tok.slice(eq + 1));
|
|
204
|
+
}
|
|
205
|
+
if (typeKeyCollision) {
|
|
206
|
+
fail(lineNo, `'do' data key "type" collides with the command type (the leading token); rename it`);
|
|
207
|
+
}
|
|
208
|
+
return { kind: "command", commands: [command] };
|
|
209
|
+
}
|
|
210
|
+
__name(parseDo, "parseDo");
|
|
211
|
+
function parseSay(line, lineNo, speakers) {
|
|
212
|
+
let speaker;
|
|
213
|
+
let expression;
|
|
214
|
+
let body = line;
|
|
215
|
+
const colon = line.indexOf(":");
|
|
216
|
+
if (colon !== -1) {
|
|
217
|
+
const header = line.slice(0, colon).trim();
|
|
218
|
+
const tokens = header.length > 0 ? header.split(/\s+/) : [];
|
|
219
|
+
const first = tokens[0];
|
|
220
|
+
if (first !== void 0 && Object.hasOwn(speakers, first)) {
|
|
221
|
+
if (tokens.length > 2) {
|
|
222
|
+
fail(lineNo, `speaker header "${header}" has too many tokens (use "speaker [face]: text")`);
|
|
223
|
+
}
|
|
224
|
+
speaker = first;
|
|
225
|
+
expression = tokens[1];
|
|
226
|
+
body = line.slice(colon + 1).trimStart();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const { text, fields, meta } = peelSayHints(body, lineNo);
|
|
230
|
+
return {
|
|
231
|
+
kind: "say",
|
|
232
|
+
...speaker !== void 0 ? { speaker } : {},
|
|
233
|
+
...expression !== void 0 ? { expression } : {},
|
|
234
|
+
text,
|
|
235
|
+
...fields,
|
|
236
|
+
...meta ? { meta } : {}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
__name(parseSay, "parseSay");
|
|
240
|
+
function peelSayHints(body, lineNo) {
|
|
241
|
+
let rest = body;
|
|
242
|
+
const fields = {};
|
|
243
|
+
const meta = {};
|
|
244
|
+
let metaCount = 0;
|
|
245
|
+
for (; ; ) {
|
|
246
|
+
const hash = /(^|\s)#(\S+)\s*$/.exec(rest);
|
|
247
|
+
if (hash) {
|
|
248
|
+
const tag = hash[2];
|
|
249
|
+
const lk = lineKey(tag);
|
|
250
|
+
if (lk !== void 0) fields.key = lk;
|
|
251
|
+
else metaCount += applyHashtag(meta, tag);
|
|
252
|
+
rest = rest.slice(0, hash.index).replace(/\s+$/, "");
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const hint = /(^|\s)(view|voice|speed|auto)=(\S+)\s*$/.exec(rest);
|
|
256
|
+
if (hint) {
|
|
257
|
+
applySayField(fields, hint[2], hint[3], lineNo);
|
|
258
|
+
rest = rest.slice(0, hint.index).replace(/\s+$/, "");
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
return { text: rest, fields, meta: metaCount > 0 ? meta : void 0 };
|
|
264
|
+
}
|
|
265
|
+
__name(peelSayHints, "peelSayHints");
|
|
266
|
+
function applySayField(fields, key, value, lineNo) {
|
|
267
|
+
switch (key) {
|
|
268
|
+
case "view":
|
|
269
|
+
fields.view = unquote(value);
|
|
270
|
+
return;
|
|
271
|
+
case "voice":
|
|
272
|
+
fields.voice = unquote(value);
|
|
273
|
+
return;
|
|
274
|
+
case "speed":
|
|
275
|
+
fields.speed = numberHint(value, lineNo, "speed");
|
|
276
|
+
return;
|
|
277
|
+
case "auto":
|
|
278
|
+
fields.autoAdvanceMs = numberHint(value, lineNo, "auto");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
__name(applySayField, "applySayField");
|
|
283
|
+
function numberHint(value, lineNo, key) {
|
|
284
|
+
const n = Number(value);
|
|
285
|
+
if (!Number.isFinite(n)) fail(lineNo, `'${key}=' expects a number, got "${value}"`);
|
|
286
|
+
return n;
|
|
287
|
+
}
|
|
288
|
+
__name(numberHint, "numberHint");
|
|
289
|
+
function parseChoice(body, lineNo) {
|
|
290
|
+
let rest = body;
|
|
291
|
+
const meta = {};
|
|
292
|
+
let metaCount = 0;
|
|
293
|
+
let once = false;
|
|
294
|
+
let disabled = false;
|
|
295
|
+
let target;
|
|
296
|
+
let condition;
|
|
297
|
+
let key;
|
|
298
|
+
for (; ; ) {
|
|
299
|
+
const hash = /(^|\s)#(\S+)\s*$/.exec(rest);
|
|
300
|
+
if (!hash) break;
|
|
301
|
+
const tag = hash[2];
|
|
302
|
+
const lk = lineKey(tag);
|
|
303
|
+
if (tag === "once") once = true;
|
|
304
|
+
else if (tag === "disabled") disabled = true;
|
|
305
|
+
else if (lk !== void 0) key = lk;
|
|
306
|
+
else metaCount += applyHashtag(meta, tag);
|
|
307
|
+
rest = rest.slice(0, hash.index).replace(/\s+$/, "");
|
|
308
|
+
}
|
|
309
|
+
const arrow = /(^|\s)->\s*(\S+)\s*$/.exec(rest);
|
|
310
|
+
const named = arrow ? null : /(^|\s)target=(\S+)\s*$/.exec(rest);
|
|
311
|
+
if (arrow) {
|
|
312
|
+
target = arrow[2];
|
|
313
|
+
rest = rest.slice(0, arrow.index).replace(/\s+$/, "");
|
|
314
|
+
} else if (named) {
|
|
315
|
+
target = named[2];
|
|
316
|
+
rest = rest.slice(0, named.index).replace(/\s+$/, "");
|
|
317
|
+
}
|
|
318
|
+
const ifm = /(^|\s)if:\s*(.+)$/.exec(rest);
|
|
319
|
+
if (ifm) {
|
|
320
|
+
const condStr = ifm[2].trim();
|
|
321
|
+
if (!condStr) fail(lineNo, "choice 'if:' has no condition");
|
|
322
|
+
condition = parseExpr(condStr);
|
|
323
|
+
rest = rest.slice(0, ifm.index).replace(/\s+$/, "");
|
|
324
|
+
}
|
|
325
|
+
const text = rest.trim();
|
|
326
|
+
if (!text) fail(lineNo, "choice has no text");
|
|
327
|
+
const unknown = firstUnknownTag(text);
|
|
328
|
+
if (unknown !== null) {
|
|
329
|
+
fail(
|
|
330
|
+
lineNo,
|
|
331
|
+
`unrecognized markup tag "[${unknown}]" in choice text \u2014 '[..]' is for inline markup only; write choice attributes as 'if: \u2026', '-> node', or '#flag'`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
text,
|
|
336
|
+
...key !== void 0 ? { key } : {},
|
|
337
|
+
...condition !== void 0 ? { condition } : {},
|
|
338
|
+
...target !== void 0 ? { target } : {},
|
|
339
|
+
...once ? { once: true } : {},
|
|
340
|
+
...disabled ? { presentation: "disabled" } : {},
|
|
341
|
+
...metaCount > 0 ? { meta } : {}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
__name(parseChoice, "parseChoice");
|
|
345
|
+
function applyHashtag(meta, tag) {
|
|
346
|
+
const colon = tag.indexOf(":");
|
|
347
|
+
if (colon === -1) meta[tag] = true;
|
|
348
|
+
else meta[tag.slice(0, colon)] = scalar(tag.slice(colon + 1));
|
|
349
|
+
return 1;
|
|
350
|
+
}
|
|
351
|
+
__name(applyHashtag, "applyHashtag");
|
|
352
|
+
function lineKey(tag) {
|
|
353
|
+
const colon = tag.indexOf(":");
|
|
354
|
+
return colon > 0 && tag.slice(0, colon) === "line" ? tag.slice(colon + 1) : void 0;
|
|
355
|
+
}
|
|
356
|
+
__name(lineKey, "lineKey");
|
|
357
|
+
var NOT_LITERAL = /* @__PURE__ */ Symbol("not-literal");
|
|
358
|
+
function numberBoolNull(raw) {
|
|
359
|
+
if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
|
|
360
|
+
if (raw === "true") return true;
|
|
361
|
+
if (raw === "false") return false;
|
|
362
|
+
if (raw === "null") return null;
|
|
363
|
+
return NOT_LITERAL;
|
|
364
|
+
}
|
|
365
|
+
__name(numberBoolNull, "numberBoolNull");
|
|
366
|
+
function scalar(raw) {
|
|
367
|
+
const lit = numberBoolNull(raw);
|
|
368
|
+
return lit === NOT_LITERAL ? unquote(raw) : lit;
|
|
369
|
+
}
|
|
370
|
+
__name(scalar, "scalar");
|
|
371
|
+
function unquote(raw) {
|
|
372
|
+
if (raw.length >= 2 && (raw.startsWith('"') && raw.endsWith('"') || raw.startsWith("'") && raw.endsWith("'"))) {
|
|
373
|
+
return raw.slice(1, -1);
|
|
374
|
+
}
|
|
375
|
+
return raw;
|
|
376
|
+
}
|
|
377
|
+
__name(unquote, "unquote");
|
|
378
|
+
function splitArgs(s) {
|
|
379
|
+
const out = [];
|
|
380
|
+
let i = 0;
|
|
381
|
+
while (i < s.length) {
|
|
382
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
383
|
+
if (i >= s.length) break;
|
|
384
|
+
let tok = "";
|
|
385
|
+
while (i < s.length && !/\s/.test(s[i])) {
|
|
386
|
+
const c = s[i];
|
|
387
|
+
if (c === '"' || c === "'") {
|
|
388
|
+
tok += c;
|
|
389
|
+
i++;
|
|
390
|
+
while (i < s.length && s[i] !== c) tok += s[i++];
|
|
391
|
+
if (i < s.length) tok += s[i++];
|
|
392
|
+
} else {
|
|
393
|
+
tok += c;
|
|
394
|
+
i++;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
out.push(tok);
|
|
398
|
+
}
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
__name(splitArgs, "splitArgs");
|
|
402
|
+
function fail(lineNo, message) {
|
|
403
|
+
throw new DialogueScriptError(`compact: line ${lineNo}: ${message}`);
|
|
404
|
+
}
|
|
405
|
+
__name(fail, "fail");
|
|
406
|
+
|
|
407
|
+
// src/core/defineScript.ts
|
|
408
|
+
function defineScript(script) {
|
|
409
|
+
return script;
|
|
410
|
+
}
|
|
411
|
+
__name(defineScript, "defineScript");
|
|
412
|
+
|
|
413
|
+
// src/core/runner.ts
|
|
414
|
+
var DialogueRunner = class {
|
|
415
|
+
constructor(script, env, handlers) {
|
|
416
|
+
this.script = script;
|
|
417
|
+
this.handlers = handlers;
|
|
418
|
+
this.nodeId = script.start;
|
|
419
|
+
this.storage = env.storage;
|
|
420
|
+
this.scope = createScope(env.storage, env.functions);
|
|
421
|
+
this.onError = env.onError;
|
|
422
|
+
}
|
|
423
|
+
script;
|
|
424
|
+
handlers;
|
|
425
|
+
static {
|
|
426
|
+
__name(this, "DialogueRunner");
|
|
427
|
+
}
|
|
428
|
+
/** `option.once` keys already picked — per-conversation **cursor** state, NOT
|
|
429
|
+
* the variable storage. Fresh per runner, so a new `play()` starts it empty
|
|
430
|
+
* (a re-played conversation re-shows its `once` options; {@link getChosenOnce}
|
|
431
|
+
* exposes the set so a save cursor could capture/restore it). */
|
|
432
|
+
chosenOnce = /* @__PURE__ */ new Set();
|
|
433
|
+
nodeId;
|
|
434
|
+
stepIndex = 0;
|
|
435
|
+
state = "idle";
|
|
436
|
+
/** "play" normally; `skip()` flips it to "skip" to fast-forward the section. */
|
|
437
|
+
runMode = "play";
|
|
438
|
+
/** Storage (write through this so a read-only `cells` accessor throws) +
|
|
439
|
+
* functions, wrapped once as the condition/`set`-value eval scope. */
|
|
440
|
+
storage;
|
|
441
|
+
scope;
|
|
442
|
+
onError;
|
|
443
|
+
/** Snapshot of the storage's variables — the `handle.getVars()` /
|
|
444
|
+
* future save-cursor view. */
|
|
445
|
+
getVars() {
|
|
446
|
+
return materialize(this.storage);
|
|
447
|
+
}
|
|
448
|
+
// ── save seam (read-only cursor getters) ──────────────────────────────────
|
|
449
|
+
// The runner's durable cursor is (nodeId, stepIndex, chosenOnce) + getVars().
|
|
450
|
+
// These getters exist so a future `SnapshotContributor` can capture/restore a
|
|
451
|
+
// conversation WITHOUT a breaking API change. Snapshot/restore itself is
|
|
452
|
+
// deliberately NOT built yet — keep these read-only.
|
|
453
|
+
/** Current node id (durable cursor; save seam). */
|
|
454
|
+
getNodeId() {
|
|
455
|
+
return this.nodeId;
|
|
456
|
+
}
|
|
457
|
+
/** Current step index within the node (durable cursor; save seam). */
|
|
458
|
+
getStepIndex() {
|
|
459
|
+
return this.stepIndex;
|
|
460
|
+
}
|
|
461
|
+
/** One-shot choice keys already picked (`option.once`); save seam. */
|
|
462
|
+
getChosenOnce() {
|
|
463
|
+
return this.chosenOnce;
|
|
464
|
+
}
|
|
465
|
+
isEnded() {
|
|
466
|
+
return this.state === "ended";
|
|
467
|
+
}
|
|
468
|
+
/** Begin at the start node. Idempotent guard against double-start. The cursor
|
|
469
|
+
* (`nodeId`/`stepIndex`) is already at the start from the ctor + field init. */
|
|
470
|
+
start() {
|
|
471
|
+
if (this.state !== "idle") return;
|
|
472
|
+
void this.run();
|
|
473
|
+
}
|
|
474
|
+
/** Advance past the current `say` line. No-op unless we're awaiting it. */
|
|
475
|
+
advance() {
|
|
476
|
+
if (this.state !== "saying") return;
|
|
477
|
+
this.stepIndex++;
|
|
478
|
+
void this.run();
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Fast-forward from the current line: run intervening commands in `skip` mode
|
|
482
|
+
* (so the game can reconstruct world state idempotently) without presenting
|
|
483
|
+
* any lines, stopping at the next choice or the end. No-op unless on a line.
|
|
484
|
+
*/
|
|
485
|
+
async skip() {
|
|
486
|
+
if (this.state !== "saying") return;
|
|
487
|
+
this.runMode = "skip";
|
|
488
|
+
this.stepIndex++;
|
|
489
|
+
await this.run();
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Public, **wait-state-free** entry the Session uses to fire a `say` line's
|
|
493
|
+
* commands at show / after-reveal / advance time. Handles built-in `set`,
|
|
494
|
+
* surfaces the rest with the current mode (or `mode`, when the Session fires the
|
|
495
|
+
* displayed line's batches as part of its own skip), and awaits `blocking` ones.
|
|
496
|
+
* Delegates to {@link executeBatch}; the runner's wait-state is untouched (the
|
|
497
|
+
* Session gates its own input).
|
|
498
|
+
*/
|
|
499
|
+
runCommands(commands, mode) {
|
|
500
|
+
return this.executeBatch(commands, mode);
|
|
501
|
+
}
|
|
502
|
+
/** Pick choice `index` (the original option index). */
|
|
503
|
+
async choose(index) {
|
|
504
|
+
if (this.state !== "choosing") return;
|
|
505
|
+
const step = this.currentStep();
|
|
506
|
+
if (!step || step.kind !== "choice") return;
|
|
507
|
+
const option = step.options[index];
|
|
508
|
+
if (!option || !this.choiceEnabled(step, index, option)) return;
|
|
509
|
+
if (option.once) this.chosenOnce.add(this.onceKey(step, index));
|
|
510
|
+
this.state = "awaiting-command";
|
|
511
|
+
await this.fireBatch(option.commands);
|
|
512
|
+
if (this.isEnded()) return;
|
|
513
|
+
if (option.target !== void 0) this.jump(option.target);
|
|
514
|
+
else this.stepIndex++;
|
|
515
|
+
void this.run();
|
|
516
|
+
}
|
|
517
|
+
// ── internal ────────────────────────────────────────────────────────────
|
|
518
|
+
/** Run non-blocking steps until we hit one that needs input, or the end. */
|
|
519
|
+
async run() {
|
|
520
|
+
for (; ; ) {
|
|
521
|
+
if (this.isEnded()) return;
|
|
522
|
+
const step = this.currentStep();
|
|
523
|
+
if (!step) {
|
|
524
|
+
this.end();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (await this.handleStep(step)) return;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** @returns true if the step blocks (waiting for advance/choose/command/end). */
|
|
531
|
+
async handleStep(step) {
|
|
532
|
+
switch (step.kind) {
|
|
533
|
+
case "say": {
|
|
534
|
+
if (this.runMode === "skip") {
|
|
535
|
+
await this.fireBatch(step.commands);
|
|
536
|
+
if (this.isEnded()) return true;
|
|
537
|
+
this.stepIndex++;
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
this.state = "saying";
|
|
541
|
+
this.handlers.onSay(step, this.speaker(step.speaker));
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
case "choice": {
|
|
545
|
+
const choices = this.resolveChoices(step);
|
|
546
|
+
if (!choices.some((c) => !c.disabled)) {
|
|
547
|
+
this.stepIndex++;
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
this.runMode = "play";
|
|
551
|
+
this.state = "choosing";
|
|
552
|
+
this.handlers.onChoice(step, choices, this.speaker(step.speaker));
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
case "command": {
|
|
556
|
+
await this.fireBatch(step.commands);
|
|
557
|
+
if (this.state === "ended") return true;
|
|
558
|
+
if (step.target !== void 0 && this.test(step.condition)) {
|
|
559
|
+
this.jump(step.target);
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
this.stepIndex++;
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
case "goto":
|
|
566
|
+
this.jump(step.target);
|
|
567
|
+
return false;
|
|
568
|
+
case "end":
|
|
569
|
+
this.end();
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
jump(target) {
|
|
574
|
+
this.nodeId = target;
|
|
575
|
+
this.stepIndex = 0;
|
|
576
|
+
}
|
|
577
|
+
end() {
|
|
578
|
+
if (this.state === "ended") return;
|
|
579
|
+
this.state = "ended";
|
|
580
|
+
this.handlers.onEnd();
|
|
581
|
+
}
|
|
582
|
+
currentStep() {
|
|
583
|
+
return this.script.nodes[this.nodeId]?.steps[this.stepIndex];
|
|
584
|
+
}
|
|
585
|
+
speaker(id) {
|
|
586
|
+
return id ? this.script.speakers?.[id] : void 0;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Resolve a choice step to its visible rows. A spent `once` option is always
|
|
590
|
+
* dropped (presentation governs condition failures only). A passing option is
|
|
591
|
+
* enabled; a failing one is returned as a `disabled` row when its
|
|
592
|
+
* `presentation` is `"disabled"`, else dropped (the default `"hidden"`).
|
|
593
|
+
*/
|
|
594
|
+
resolveChoices(step) {
|
|
595
|
+
const out = [];
|
|
596
|
+
step.options.forEach((option, index) => {
|
|
597
|
+
if (this.isSpent(step, index)) return;
|
|
598
|
+
if (this.test(option.condition)) out.push({ index, option });
|
|
599
|
+
else if (option.presentation === "disabled") out.push({ index, option, disabled: true });
|
|
600
|
+
});
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
/** Whether option `index` can actually be picked — the gate `choose()` uses.
|
|
604
|
+
* A spent `once` option or a failing condition refuses (a `"disabled"` row is
|
|
605
|
+
* shown but still unpickable, so this stays the single selection authority). */
|
|
606
|
+
choiceEnabled(step, index, option) {
|
|
607
|
+
return !this.isSpent(step, index) && this.test(option.condition);
|
|
608
|
+
}
|
|
609
|
+
/** A `once` option already chosen this run — always dropped from the menu
|
|
610
|
+
* regardless of `presentation`. Single source of truth for the once-gate,
|
|
611
|
+
* shared by `resolveChoices` and `choiceEnabled`. Reads the option from
|
|
612
|
+
* `step.options[index]`, so `(step, index)` is the only input. */
|
|
613
|
+
isSpent(step, index) {
|
|
614
|
+
const option = step.options[index];
|
|
615
|
+
return option?.once === true && this.chosenOnce.has(this.onceKey(step, index));
|
|
616
|
+
}
|
|
617
|
+
onceKey(step, index) {
|
|
618
|
+
return `${this.nodeId}#${this.stepIndex}#${index}#${step.options[index]?.text ?? ""}`;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Fire an inline command batch (a `command` step or a chosen option). Manages
|
|
622
|
+
* wait-state: enters `awaiting-command` up front when the batch contains a
|
|
623
|
+
* blocking command, so a stray advance/confirm during the await is ignored; the
|
|
624
|
+
* caller transitions out of the state afterwards. The work itself goes through
|
|
625
|
+
* the wait-state-free {@link executeBatch}.
|
|
626
|
+
*/
|
|
627
|
+
async fireBatch(commands) {
|
|
628
|
+
if (!commands || commands.length === 0) return;
|
|
629
|
+
if (commands.some((c) => c.blocking)) this.state = "awaiting-command";
|
|
630
|
+
await this.executeBatch(commands);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* The wait-state-free command executor, shared by {@link fireBatch} (inline
|
|
634
|
+
* firing) and {@link runCommands} (the Session's line-timed firing). Applies
|
|
635
|
+
* built-in `set`; surfaces the rest to the host with the current mode; awaits
|
|
636
|
+
* `blocking` handlers and fire-and-forgets the others. Touches no wait-state.
|
|
637
|
+
*/
|
|
638
|
+
async executeBatch(commands, mode = this.runMode) {
|
|
639
|
+
if (!commands) return;
|
|
640
|
+
for (const cmd of commands) {
|
|
641
|
+
if (cmd.type === "set" && typeof cmd.var === "string") {
|
|
642
|
+
const value = cmd.value;
|
|
643
|
+
const next = isExpr(value) ? evaluate(value, this.scope) : value;
|
|
644
|
+
try {
|
|
645
|
+
this.storage.set(cmd.var, next);
|
|
646
|
+
} catch (e) {
|
|
647
|
+
this.onError?.(
|
|
648
|
+
`ignored "set ${cmd.var}": ${e instanceof Error ? e.message : String(e)}`,
|
|
649
|
+
e
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
let result;
|
|
655
|
+
try {
|
|
656
|
+
result = this.handlers.onCommand(cmd, this.commandContext(mode));
|
|
657
|
+
} catch {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (!isPromise(result)) continue;
|
|
661
|
+
if (cmd.blocking) {
|
|
662
|
+
try {
|
|
663
|
+
await result;
|
|
664
|
+
} catch {
|
|
665
|
+
}
|
|
666
|
+
if (this.isEnded()) return;
|
|
667
|
+
} else {
|
|
668
|
+
void Promise.resolve(result).catch(() => {
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/** The context handed to a command handler. `setVar` writes through the
|
|
674
|
+
* conversation's storage (guarded by the session for staleness), the same
|
|
675
|
+
* path as the `set` built-in — so the skill-check seam and `set` share one
|
|
676
|
+
* guarded write. */
|
|
677
|
+
commandContext(mode) {
|
|
678
|
+
return { mode, setVar: /* @__PURE__ */ __name((name, value) => this.storage.set(name, value), "setVar") };
|
|
679
|
+
}
|
|
680
|
+
test(condition) {
|
|
681
|
+
return holds(condition, this.scope);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
function isPromise(v) {
|
|
685
|
+
return typeof v === "object" && v !== null && typeof v.then === "function";
|
|
686
|
+
}
|
|
687
|
+
__name(isPromise, "isPromise");
|
|
688
|
+
|
|
689
|
+
// src/core/session.ts
|
|
690
|
+
var EMPTY_VARS = Object.freeze({});
|
|
691
|
+
var DialogueSession = class {
|
|
692
|
+
constructor(channels, opts = {}) {
|
|
693
|
+
this.channels = channels;
|
|
694
|
+
this.opts = opts;
|
|
695
|
+
this.i18n = opts.i18n ?? new IdentityI18n();
|
|
696
|
+
this.skipMul = opts.skipMultiplier ?? 4;
|
|
697
|
+
this.defaultStorage = opts.storage ?? new MemoryVariableStorage();
|
|
698
|
+
this.defaultFunctions = opts.functions ?? {};
|
|
699
|
+
this.defaultCommands = opts.commands ?? {};
|
|
700
|
+
this.defaultFallback = opts.fallbackCommand;
|
|
701
|
+
this.channels.text.setRevealListener(() => this.handleRevealComplete());
|
|
702
|
+
this.channels.text.setBeatListener((beat) => this.handleRevealBeat(beat));
|
|
703
|
+
this.channels.choices.onChoiceChosen = (position) => this.confirmAt(position);
|
|
704
|
+
}
|
|
705
|
+
channels;
|
|
706
|
+
opts;
|
|
707
|
+
static {
|
|
708
|
+
__name(this, "DialogueSession");
|
|
709
|
+
}
|
|
710
|
+
i18n;
|
|
711
|
+
skipMul;
|
|
712
|
+
/** Controller-installed environment (persists across plays); per-`play()`
|
|
713
|
+
* overrides are layered on top into the resolved fields below. */
|
|
714
|
+
defaultStorage;
|
|
715
|
+
defaultFunctions;
|
|
716
|
+
defaultCommands;
|
|
717
|
+
defaultFallback;
|
|
718
|
+
// Resolved per play() (controller install + call-site overrides).
|
|
719
|
+
storage;
|
|
720
|
+
functions = {};
|
|
721
|
+
commands = {};
|
|
722
|
+
fallbackCommand;
|
|
723
|
+
// Fields use explicit `| undefined` (not `?`) so reassigning `undefined`
|
|
724
|
+
// (e.g. `stop()` nulling the cursor) is legal under the repo's
|
|
725
|
+
// `exactOptionalPropertyTypes`.
|
|
726
|
+
runner;
|
|
727
|
+
script;
|
|
728
|
+
mode = "idle";
|
|
729
|
+
scriptId = "";
|
|
730
|
+
saying;
|
|
731
|
+
autoTimer;
|
|
732
|
+
/** Default auto-advance delay (ms) applied to lines without their own
|
|
733
|
+
* `autoAdvanceMs`. `null` = off (manual advance). Set via {@link setAutoAdvance}. */
|
|
734
|
+
autoAdvanceDefault = null;
|
|
735
|
+
resolved = [];
|
|
736
|
+
selected = 0;
|
|
737
|
+
/** Count of in-flight blocking line-command batches (show/afterReveal/advance).
|
|
738
|
+
* Input is gated while > 0. An ownership counter (not a shared boolean) so an
|
|
739
|
+
* overlapping batch resolving — e.g. the afterReveal batch finishing while a
|
|
740
|
+
* long blocking `show` command is still awaited — can't drop a gate it
|
|
741
|
+
* doesn't own. */
|
|
742
|
+
blockedCount = 0;
|
|
743
|
+
/** True between an advance request and the runner stepping off the line —
|
|
744
|
+
* guards against a second advance double-firing `advance`-timed commands. */
|
|
745
|
+
advancing = false;
|
|
746
|
+
/** True once the current line's `advance`-timed commands have fired, so a
|
|
747
|
+
* second advance() while the runner is still stepping (e.g. awaiting a
|
|
748
|
+
* blocking command step) can't re-fire them against the stale line. */
|
|
749
|
+
advanceFired = false;
|
|
750
|
+
/** True once the current line's `afterReveal`-timed commands have fired
|
|
751
|
+
* (normally via handleRevealComplete; skip() fires them early when the line
|
|
752
|
+
* hasn't finished revealing). */
|
|
753
|
+
afterRevealFired = false;
|
|
754
|
+
/** Latched by the first confirm() until the runner produces its next state
|
|
755
|
+
* (handleSay/handleChoice/handleEnd), so mashing confirm while the runner
|
|
756
|
+
* awaits the option's blocking commands can't emit duplicate onChoiceMade
|
|
757
|
+
* events (`mode` stays "choosing" for that whole window). */
|
|
758
|
+
confirming = false;
|
|
759
|
+
/** Bumped by every stop()/play(). A suspended async continuation from a prior
|
|
760
|
+
* conversation captures this and bails on resume if it changed, so it can't
|
|
761
|
+
* drive (advance / show the caret on) the runner of a *new* conversation. */
|
|
762
|
+
generation = 0;
|
|
763
|
+
// ── lifecycle levers — host-level, NOT conversation state ──────────────
|
|
764
|
+
/** `setHidden` — visual only; gates every channel's `setVisible`. Survives
|
|
765
|
+
* `stop()`/`play()` (a host that hides for a cutscene and forgets to unhide
|
|
766
|
+
* gets what it asked for) — so it is deliberately NOT reset by `stop()`. */
|
|
767
|
+
hidden = false;
|
|
768
|
+
/** `setPaused` — freezes the update loop AND the input-agnostic API. State is
|
|
769
|
+
* left fully intact (no generation bump); also host-level, survives replays. */
|
|
770
|
+
paused = false;
|
|
771
|
+
/** Whether the chrome / body text are part of the CURRENT choice's layout
|
|
772
|
+
* (false when a self-contained bubble panel owns the prompt) — remembered so
|
|
773
|
+
* {@link applyVisibility} can recompute after a hide toggle mid-choice. */
|
|
774
|
+
choiceShowsChrome = false;
|
|
775
|
+
choiceShowsBody = false;
|
|
776
|
+
/** Plain (speaker, text) of the line on screen — for the reveal-completed
|
|
777
|
+
* event, which fires after `present` has discarded the resolved string. */
|
|
778
|
+
currentLine;
|
|
779
|
+
/** The full {@link PresentedLine} on screen — handed to an extra channel's
|
|
780
|
+
* `revealComplete` (the session discards the local `line` after present). */
|
|
781
|
+
currentPresented;
|
|
782
|
+
/** Host-registered extra channels (Voice / Shop / CameraEffects / History).
|
|
783
|
+
* The session fans its cross-cutting stream to these alongside the typed trio
|
|
784
|
+
* and folds their `isRevealComplete()` into the auto-advance gate. */
|
|
785
|
+
extras = [];
|
|
786
|
+
/**
|
|
787
|
+
* Begin a conversation. `play(script)` is **content-only** — the storage,
|
|
788
|
+
* functions, and commands are installed on the session; `overrides` layers
|
|
789
|
+
* per-conversation specifics on top (a scoped `storage`, extra `functions` /
|
|
790
|
+
* `commands`). Declared defaults seed into the storage **only if absent**
|
|
791
|
+
* (game-linked values win); variables persist across plays. Returns a
|
|
792
|
+
* generation-stamped {@link DialogueHandle} for live `setVar` / `getVars`.
|
|
793
|
+
*/
|
|
794
|
+
play(rawScript, overrides) {
|
|
795
|
+
const script = loadScript(rawScript);
|
|
796
|
+
const analysis = analyzeScript(script);
|
|
797
|
+
const storage = overrides?.storage ?? this.defaultStorage;
|
|
798
|
+
const functions = { ...this.defaultFunctions, ...overrides?.functions };
|
|
799
|
+
const commands = { ...this.defaultCommands, ...overrides?.commands };
|
|
800
|
+
const fallbackCommand = overrides?.fallbackCommand ?? this.defaultFallback;
|
|
801
|
+
validatePlay(analysis, { storage, functions, commands, fallbackCommand });
|
|
802
|
+
for (const [name, value] of Object.entries(script.declare ?? {})) {
|
|
803
|
+
if (storage.has(name)) continue;
|
|
804
|
+
try {
|
|
805
|
+
storage.set(name, value);
|
|
806
|
+
} catch (cause) {
|
|
807
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
808
|
+
throw new DialoguePlayError(
|
|
809
|
+
`cannot seed declared default "${name}": ${detail} (a read-only / pure cells() storage has no writable slot \u2014 compose it with a MemoryVariableStorage)`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
this.stop();
|
|
814
|
+
this.script = script;
|
|
815
|
+
this.scriptId = script.id;
|
|
816
|
+
this.storage = storage;
|
|
817
|
+
this.functions = functions;
|
|
818
|
+
this.commands = commands;
|
|
819
|
+
this.fallbackCommand = fallbackCommand;
|
|
820
|
+
const gen = this.generation;
|
|
821
|
+
const live = /* @__PURE__ */ __name(() => gen === this.generation, "live");
|
|
822
|
+
const guarded = {
|
|
823
|
+
get: /* @__PURE__ */ __name((name) => storage.get(name), "get"),
|
|
824
|
+
set: /* @__PURE__ */ __name((name, value) => {
|
|
825
|
+
if (live()) storage.set(name, value);
|
|
826
|
+
}, "set"),
|
|
827
|
+
has: /* @__PURE__ */ __name((name) => storage.has(name), "has"),
|
|
828
|
+
entries: /* @__PURE__ */ __name(() => storage.entries(), "entries")
|
|
829
|
+
};
|
|
830
|
+
const runner = new DialogueRunner(
|
|
831
|
+
script,
|
|
832
|
+
{ storage: guarded, functions, onError: this.opts.onError },
|
|
833
|
+
{
|
|
834
|
+
onSay: /* @__PURE__ */ __name((step, speaker) => {
|
|
835
|
+
if (live()) this.handleSay(step, speaker);
|
|
836
|
+
}, "onSay"),
|
|
837
|
+
onChoice: /* @__PURE__ */ __name((step, choices, speaker) => {
|
|
838
|
+
if (live()) this.handleChoice(step, choices, speaker);
|
|
839
|
+
}, "onChoice"),
|
|
840
|
+
onCommand: /* @__PURE__ */ __name((command, ctx) => live() ? this.handleCommand(command, ctx) : void 0, "onCommand"),
|
|
841
|
+
onEnd: /* @__PURE__ */ __name(() => {
|
|
842
|
+
if (live()) this.handleEnd();
|
|
843
|
+
}, "onEnd")
|
|
844
|
+
}
|
|
845
|
+
);
|
|
846
|
+
this.runner = runner;
|
|
847
|
+
this.opts.onStarted?.({ scriptId: this.scriptId });
|
|
848
|
+
runner.start();
|
|
849
|
+
return {
|
|
850
|
+
setVar: /* @__PURE__ */ __name((name, value) => guarded.set(name, value), "setVar"),
|
|
851
|
+
getVars: /* @__PURE__ */ __name(() => gen === this.generation ? materialize(storage) : EMPTY_VARS, "getVars")
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
isActive() {
|
|
855
|
+
return this.mode === "saying" || this.mode === "choosing";
|
|
856
|
+
}
|
|
857
|
+
isChoosing() {
|
|
858
|
+
return this.mode === "choosing";
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Register an extra channel (Voice / Shop / CameraEffects / History) — the
|
|
862
|
+
* open-ended companion to the built-in trio. It receives the cross-cutting
|
|
863
|
+
* stream (`present` / `command` / `clear` / `setVisible` / `setPaused` /
|
|
864
|
+
* `completeReveal` / `update`) and can gate auto-advance via
|
|
865
|
+
* `isRevealComplete()`. Returns a disposer that unregisters **and** disposes
|
|
866
|
+
* it. On register the channel catches up the current `setVisible` / `setPaused`
|
|
867
|
+
* lever state ONLY — no content replay (replaying `present` would re-trigger a
|
|
868
|
+
* voice clip). Safe to call mid-conversation.
|
|
869
|
+
*/
|
|
870
|
+
addChannel(ch) {
|
|
871
|
+
this.extras.push(ch);
|
|
872
|
+
try {
|
|
873
|
+
ch.setVisible?.(!this.hidden);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
this.opts.onError?.("dialogue: channel setVisible() failed", error);
|
|
876
|
+
}
|
|
877
|
+
try {
|
|
878
|
+
ch.setPaused?.(this.paused);
|
|
879
|
+
} catch (error) {
|
|
880
|
+
this.opts.onError?.("dialogue: channel setPaused() failed", error);
|
|
881
|
+
}
|
|
882
|
+
let disposed = false;
|
|
883
|
+
return () => {
|
|
884
|
+
if (disposed) return;
|
|
885
|
+
disposed = true;
|
|
886
|
+
const i = this.extras.indexOf(ch);
|
|
887
|
+
if (i >= 0) this.extras.splice(i, 1);
|
|
888
|
+
try {
|
|
889
|
+
ch.dispose?.();
|
|
890
|
+
} catch (error) {
|
|
891
|
+
this.opts.onError?.("dialogue: channel dispose() failed", error);
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
// ── lifecycle levers ──────────────────────────────────────────────────
|
|
896
|
+
/**
|
|
897
|
+
* Hide or show the whole dialogue UI — purely visual, state-preserving.
|
|
898
|
+
* Drives every channel's `setVisible`; the conversation keeps running
|
|
899
|
+
* underneath (reveal, timers, cursor intact). Host-level and **persistent**:
|
|
900
|
+
* it survives `stop()` and the next `play()`, so a cutscene that hides and
|
|
901
|
+
* forgets to unhide stays hidden — call `setHidden(false)` to restore.
|
|
902
|
+
*/
|
|
903
|
+
setHidden(hidden) {
|
|
904
|
+
this.hidden = hidden;
|
|
905
|
+
this.applyVisibility();
|
|
906
|
+
}
|
|
907
|
+
/** True while the UI is hidden via {@link setHidden}. */
|
|
908
|
+
isHidden() {
|
|
909
|
+
return this.hidden;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Freeze or resume the conversation — `update()` no-ops (reveal,
|
|
913
|
+
* auto-advance clock, caret blink, avatar anim all freeze since they are
|
|
914
|
+
* dt-driven) and the input-agnostic API (`advance`/`confirm`/`choose`/
|
|
915
|
+
* `moveSelection`/`selectAt`/`skip`) no-ops. State is left fully intact: no
|
|
916
|
+
* generation bump, and `lineBlocked`/`advancing`/`autoTimer` survive. It does
|
|
917
|
+
* NOT block host-driven writes (`handle.setVar` / `ctx.setVar` / storage) —
|
|
918
|
+
* only player-facing time + input freeze. Host-level and persistent like hide.
|
|
919
|
+
*/
|
|
920
|
+
setPaused(paused) {
|
|
921
|
+
this.paused = paused;
|
|
922
|
+
for (const ch of this.extras) {
|
|
923
|
+
try {
|
|
924
|
+
ch.setPaused?.(paused);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
this.opts.onError?.("dialogue: channel setPaused() failed", error);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/** True while the conversation is frozen via {@link setPaused}. */
|
|
931
|
+
isPaused() {
|
|
932
|
+
return this.paused;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Push the current desired visibility to every channel: a channel shows when
|
|
936
|
+
* it has content for the current mode AND the host hasn't hidden the UI. The
|
|
937
|
+
* single visibility authority — `setHidden` and every line/choice transition
|
|
938
|
+
* route through here, so the host-hidden lever composes cleanly with per-line
|
|
939
|
+
* content and a custom chrome (which may not implement the optional `present`)
|
|
940
|
+
* is still reliably hidden by the explicit `setVisible(false)`.
|
|
941
|
+
*/
|
|
942
|
+
applyVisibility() {
|
|
943
|
+
const shown = !this.hidden;
|
|
944
|
+
const saying = this.mode === "saying";
|
|
945
|
+
const choosing = this.mode === "choosing";
|
|
946
|
+
this.channels.text.setVisible(shown && (saying || choosing && this.choiceShowsBody));
|
|
947
|
+
this.channels.choices.setVisible(shown && choosing);
|
|
948
|
+
this.channels.chrome?.setVisible(shown && (saying || choosing && this.choiceShowsChrome));
|
|
949
|
+
this.channels.avatar?.setVisible?.(shown && (saying || choosing));
|
|
950
|
+
for (const ch of this.extras) {
|
|
951
|
+
try {
|
|
952
|
+
ch.setVisible?.(shown);
|
|
953
|
+
} catch (error) {
|
|
954
|
+
this.opts.onError?.("dialogue: channel setVisible() failed", error);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/** Clear every presentation channel's content — text, choices, chrome
|
|
959
|
+
* (nameplate + caret + line), the avatar, and the registered extras. The
|
|
960
|
+
* shared teardown for {@link stop} and the ended state; it touches no session
|
|
961
|
+
* bookkeeping or visibility (the caller resets its own state, then
|
|
962
|
+
* {@link goIdle} reasserts visibility). */
|
|
963
|
+
clearAllChannels() {
|
|
964
|
+
this.channels.text.clear();
|
|
965
|
+
this.channels.choices.clear();
|
|
966
|
+
this.channels.chrome?.setContinueVisible(false);
|
|
967
|
+
this.channels.chrome?.setNameplate(void 0);
|
|
968
|
+
this.channels.chrome?.present?.(void 0);
|
|
969
|
+
this.channels.avatar?.setSpeaker(void 0);
|
|
970
|
+
this.channels.avatar?.setSpeaking(false);
|
|
971
|
+
this.channels.avatar?.present?.(void 0);
|
|
972
|
+
for (const ch of this.extras) {
|
|
973
|
+
try {
|
|
974
|
+
ch.clear?.();
|
|
975
|
+
} catch (error) {
|
|
976
|
+
this.opts.onError?.("dialogue: channel clear() failed", error);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
/** Drop to a quiescent presentation state (`mode` "idle" or "ended"): reset the
|
|
981
|
+
* per-line/choice bookkeeping, clear every channel, and reassert visibility
|
|
982
|
+
* (idle/ended → nothing shown, honestly via each channel's `setVisible`,
|
|
983
|
+
* preserving the host-hidden lever). The caller owns any further reset —
|
|
984
|
+
* {@link stop} also abandons the runner + timing latches. */
|
|
985
|
+
goIdle(mode) {
|
|
986
|
+
this.mode = mode;
|
|
987
|
+
this.saying = void 0;
|
|
988
|
+
this.resolved = [];
|
|
989
|
+
this.confirming = false;
|
|
990
|
+
this.currentLine = void 0;
|
|
991
|
+
this.currentPresented = void 0;
|
|
992
|
+
this.choiceShowsChrome = false;
|
|
993
|
+
this.choiceShowsBody = false;
|
|
994
|
+
this.clearAllChannels();
|
|
995
|
+
this.applyVisibility();
|
|
996
|
+
}
|
|
997
|
+
/** Abandon the current conversation and reset to idle (clears all visuals).
|
|
998
|
+
* Useful for ambient/eavesdrop dialogue that should stop when out of range. */
|
|
999
|
+
stop() {
|
|
1000
|
+
this.generation++;
|
|
1001
|
+
this.runner = void 0;
|
|
1002
|
+
this.autoTimer = void 0;
|
|
1003
|
+
this.blockedCount = 0;
|
|
1004
|
+
this.advancing = false;
|
|
1005
|
+
this.advanceFired = false;
|
|
1006
|
+
this.afterRevealFired = false;
|
|
1007
|
+
this.goIdle("idle");
|
|
1008
|
+
}
|
|
1009
|
+
update(dt) {
|
|
1010
|
+
if (this.paused) return;
|
|
1011
|
+
this.channels.text.update(dt);
|
|
1012
|
+
this.channels.chrome?.update(dt);
|
|
1013
|
+
this.channels.avatar?.update(dt);
|
|
1014
|
+
for (const ch of this.extras) {
|
|
1015
|
+
try {
|
|
1016
|
+
ch.update?.(dt);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
this.opts.onError?.("dialogue: channel update() failed", error);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (!this.runner || this.mode !== "saying") return;
|
|
1022
|
+
if (this.autoTimer !== void 0 && this.allRevealsComplete()) {
|
|
1023
|
+
this.autoTimer -= dt;
|
|
1024
|
+
if (this.autoTimer <= 0 && !this.lineBlocked && !this.advancing) {
|
|
1025
|
+
this.autoTimer = void 0;
|
|
1026
|
+
this.opts.onAutoAdvance?.({ scriptId: this.scriptId });
|
|
1027
|
+
this.advance();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
/** True while any blocking line-command batch is awaited (input is gated). */
|
|
1032
|
+
get lineBlocked() {
|
|
1033
|
+
return this.blockedCount > 0;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* The auto-advance gate: the text reveal AND every registered extra channel
|
|
1037
|
+
* that *gates* (implements `isRevealComplete`) report complete. The clock is
|
|
1038
|
+
* armed on the text reveal alone (see `handleRevealComplete`/`setAutoAdvance`)
|
|
1039
|
+
* but only counts down once this is true — so a voice clip outlasting the
|
|
1040
|
+
* typewriter holds the line for `max(clipEnd, revealEnd)` with no duration
|
|
1041
|
+
* plumbing. A channel without the method never gates (a pure observer).
|
|
1042
|
+
*/
|
|
1043
|
+
allRevealsComplete() {
|
|
1044
|
+
if (!this.channels.text.isRevealComplete()) return false;
|
|
1045
|
+
for (const ch of this.extras) {
|
|
1046
|
+
if (ch.isRevealComplete && !ch.isRevealComplete()) return false;
|
|
1047
|
+
}
|
|
1048
|
+
return true;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* The storage read view for `{token}` interpolation at *this* present-time.
|
|
1052
|
+
* Materialized per evaluation so an earlier command's `set` shows up on a
|
|
1053
|
+
* later line; already-shown lines never re-render.
|
|
1054
|
+
*/
|
|
1055
|
+
readView() {
|
|
1056
|
+
return this.storage ? materialize(this.storage) : {};
|
|
1057
|
+
}
|
|
1058
|
+
// ── input-agnostic API ────────────────────────────────────────────────────
|
|
1059
|
+
/** Primary action. Saying → reveal-all if typing, else next line. Choosing → confirm. */
|
|
1060
|
+
advance() {
|
|
1061
|
+
if (this.paused) return;
|
|
1062
|
+
if (this.lineBlocked || this.advancing) return;
|
|
1063
|
+
if (this.mode === "saying") {
|
|
1064
|
+
if (this.channels.text.isRevealing()) {
|
|
1065
|
+
this.channels.text.completeReveal();
|
|
1066
|
+
for (const ch of this.extras) {
|
|
1067
|
+
try {
|
|
1068
|
+
ch.completeReveal?.();
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
this.opts.onError?.("dialogue: channel completeReveal() failed", error);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
} else void this.advanceLine();
|
|
1074
|
+
} else if (this.mode === "choosing") {
|
|
1075
|
+
this.confirm();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
/** Fire any `advance`-timed line commands, then step the runner off the line.
|
|
1079
|
+
* `advancing` is held for the whole turn so a second advance can't re-fire
|
|
1080
|
+
* the (possibly non-blocking) `advance` commands before the runner steps. */
|
|
1081
|
+
async advanceLine() {
|
|
1082
|
+
const gen = this.generation;
|
|
1083
|
+
this.advancing = true;
|
|
1084
|
+
try {
|
|
1085
|
+
if (!this.advanceFired) {
|
|
1086
|
+
this.advanceFired = true;
|
|
1087
|
+
await this.fireLineCommands("advance");
|
|
1088
|
+
}
|
|
1089
|
+
if (gen !== this.generation || this.mode !== "saying") return;
|
|
1090
|
+
this.runner?.advance();
|
|
1091
|
+
} finally {
|
|
1092
|
+
if (gen === this.generation) this.advancing = false;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Fast-forward the current section: run intervening commands (in skip mode)
|
|
1097
|
+
* without presenting, stopping at the next choice or the end. No-op unless a
|
|
1098
|
+
* line is showing.
|
|
1099
|
+
*/
|
|
1100
|
+
skip() {
|
|
1101
|
+
if (this.paused) return;
|
|
1102
|
+
if (this.mode !== "saying" || this.lineBlocked || this.advancing) return;
|
|
1103
|
+
this.opts.onSkipUsed?.({ scriptId: this.scriptId });
|
|
1104
|
+
for (const ch of this.extras) {
|
|
1105
|
+
try {
|
|
1106
|
+
ch.completeReveal?.();
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
this.opts.onError?.("dialogue: channel completeReveal() failed", error);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
void this.skipLine();
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Fire the *displayed* line's not-yet-fired batches in skip mode — the runner
|
|
1115
|
+
* fires every skipped line's commands for world reconstruction, so dropping
|
|
1116
|
+
* the current line's `afterReveal`/`advance` batches would diverge from
|
|
1117
|
+
* normal play — then fast-forward the runner.
|
|
1118
|
+
*/
|
|
1119
|
+
async skipLine() {
|
|
1120
|
+
const gen = this.generation;
|
|
1121
|
+
this.advancing = true;
|
|
1122
|
+
try {
|
|
1123
|
+
if (!this.afterRevealFired) {
|
|
1124
|
+
this.afterRevealFired = true;
|
|
1125
|
+
await this.fireLineCommands("afterReveal", "skip");
|
|
1126
|
+
if (gen !== this.generation || this.mode !== "saying") return;
|
|
1127
|
+
}
|
|
1128
|
+
if (!this.advanceFired) {
|
|
1129
|
+
this.advanceFired = true;
|
|
1130
|
+
await this.fireLineCommands("advance", "skip");
|
|
1131
|
+
if (gen !== this.generation || this.mode !== "saying") return;
|
|
1132
|
+
}
|
|
1133
|
+
await this.runner?.skip();
|
|
1134
|
+
} finally {
|
|
1135
|
+
if (gen === this.generation) this.advancing = false;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Side-effect-free lookahead: the lines a node would show along its linear
|
|
1140
|
+
* path — following `goto` and conditional `command` jumps using the *current*
|
|
1141
|
+
* variable snapshot — stopping at the first choice or the end. Runs no
|
|
1142
|
+
* commands and mutates nothing. For a "skip with a summary" affordance.
|
|
1143
|
+
*/
|
|
1144
|
+
preview(nodeId, limit = 64) {
|
|
1145
|
+
const script = this.script;
|
|
1146
|
+
const storage = this.storage;
|
|
1147
|
+
if (!script || !storage) return [];
|
|
1148
|
+
const view = materialize(storage);
|
|
1149
|
+
const scope = createScope(storage, this.functions);
|
|
1150
|
+
const out = [];
|
|
1151
|
+
let node = nodeId;
|
|
1152
|
+
let i = 0;
|
|
1153
|
+
for (let guard = 0; guard < limit; guard++) {
|
|
1154
|
+
const step = script.nodes[node]?.steps[i];
|
|
1155
|
+
if (!step) break;
|
|
1156
|
+
if (step.kind === "say") {
|
|
1157
|
+
const speaker = step.speaker ? script.speakers?.[step.speaker] : void 0;
|
|
1158
|
+
const name = speaker ? this.i18n.t(speaker.nameKey, speaker.name, view) : void 0;
|
|
1159
|
+
out.push({
|
|
1160
|
+
speaker: name,
|
|
1161
|
+
text: stripMarkup(this.i18n.t(step.key, step.text, view))
|
|
1162
|
+
});
|
|
1163
|
+
i++;
|
|
1164
|
+
} else if (step.kind === "command") {
|
|
1165
|
+
if (step.target !== void 0 && holds(step.condition, scope)) {
|
|
1166
|
+
node = step.target;
|
|
1167
|
+
i = 0;
|
|
1168
|
+
} else {
|
|
1169
|
+
i++;
|
|
1170
|
+
}
|
|
1171
|
+
} else if (step.kind === "goto") {
|
|
1172
|
+
node = step.target;
|
|
1173
|
+
i = 0;
|
|
1174
|
+
} else {
|
|
1175
|
+
break;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return out;
|
|
1179
|
+
}
|
|
1180
|
+
/** Move the choice cursor by `delta`, skipping disabled rows and wrapping.
|
|
1181
|
+
* No-op outside a choice, and a zero `delta` is a no-op (no cursor move, no
|
|
1182
|
+
* event). A move that steps over disabled rows fires exactly one
|
|
1183
|
+
* selection-changed event, for the row it lands on. */
|
|
1184
|
+
moveSelection(delta) {
|
|
1185
|
+
if (this.paused) return;
|
|
1186
|
+
if (this.mode !== "choosing" || this.confirming) return;
|
|
1187
|
+
if (this.resolved.length === 0 || delta === 0) return;
|
|
1188
|
+
const dir = delta < 0 ? -1 : 1;
|
|
1189
|
+
let pos = this.selected;
|
|
1190
|
+
for (let i = 0; i < Math.abs(delta); i++) {
|
|
1191
|
+
pos = this.nextEnabled(pos, dir);
|
|
1192
|
+
}
|
|
1193
|
+
if (pos === this.selected) return;
|
|
1194
|
+
this.selected = pos;
|
|
1195
|
+
this.channels.choices.highlight(this.selected);
|
|
1196
|
+
this.emitSelectionChanged();
|
|
1197
|
+
}
|
|
1198
|
+
/** Highlight a choice by absolute position (e.g. pointer hover). No wrap;
|
|
1199
|
+
* a disabled row is skipped (the cursor stays put). */
|
|
1200
|
+
selectAt(position) {
|
|
1201
|
+
if (this.paused) return;
|
|
1202
|
+
if (this.mode !== "choosing" || this.confirming) return;
|
|
1203
|
+
const n = this.resolved.length;
|
|
1204
|
+
if (n === 0 || position < 0 || position >= n || position === this.selected)
|
|
1205
|
+
return;
|
|
1206
|
+
if (this.resolved[position]?.disabled) return;
|
|
1207
|
+
this.selected = position;
|
|
1208
|
+
this.channels.choices.highlight(this.selected);
|
|
1209
|
+
this.emitSelectionChanged();
|
|
1210
|
+
}
|
|
1211
|
+
/** The next enabled choice position from `from` in direction `dir` (±1),
|
|
1212
|
+
* wrapping. Returns `from` when no other enabled row exists (so a single
|
|
1213
|
+
* enabled option among disabled ones never moves). */
|
|
1214
|
+
nextEnabled(from, dir) {
|
|
1215
|
+
const n = this.resolved.length;
|
|
1216
|
+
let pos = from;
|
|
1217
|
+
for (let i = 0; i < n; i++) {
|
|
1218
|
+
pos = (pos + dir + n) % n;
|
|
1219
|
+
if (pos === from) break;
|
|
1220
|
+
if (!this.resolved[pos]?.disabled) return pos;
|
|
1221
|
+
}
|
|
1222
|
+
return from;
|
|
1223
|
+
}
|
|
1224
|
+
/** Fire onSelectionChanged for the currently-highlighted choice (keyboard nav
|
|
1225
|
+
* and pointer hover both land here — one canonical selection event). */
|
|
1226
|
+
emitSelectionChanged() {
|
|
1227
|
+
const chosen = this.resolved[this.selected];
|
|
1228
|
+
if (!chosen) return;
|
|
1229
|
+
this.opts.onSelectionChanged?.({
|
|
1230
|
+
index: chosen.index,
|
|
1231
|
+
text: stripMarkup(
|
|
1232
|
+
this.i18n.t(chosen.option.key, chosen.option.text, this.readView())
|
|
1233
|
+
)
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
/** Commit the highlighted choice. */
|
|
1237
|
+
confirm() {
|
|
1238
|
+
this.commit(this.selected);
|
|
1239
|
+
}
|
|
1240
|
+
/** Commit by original option index (e.g. a direct pointer hit, or the
|
|
1241
|
+
* timed-choice recipe firing its default). Refuses an unknown or disabled
|
|
1242
|
+
* option. */
|
|
1243
|
+
choose(optionIndex) {
|
|
1244
|
+
this.commit(this.resolved.findIndex((c) => c.index === optionIndex));
|
|
1245
|
+
}
|
|
1246
|
+
/** Commit by display position (a pointer hit on a row). The position-keyed
|
|
1247
|
+
* counterpart to {@link choose}; refuses an out-of-range or disabled row.
|
|
1248
|
+
* Used by the pointer binding and the presenter pointer-commit seam. */
|
|
1249
|
+
confirmAt(position) {
|
|
1250
|
+
this.commit(position);
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* The single choice-commit authority: every commit path routes here after
|
|
1254
|
+
* translating its argument to a display position. It guards (paused / not
|
|
1255
|
+
* choosing / already confirming / a missing or disabled row), latches against a
|
|
1256
|
+
* double-commit, fires `onChoiceMade`, and steps the runner. The latch (not the
|
|
1257
|
+
* runner) is what guarantees a single commit: `mode` stays "choosing" while the
|
|
1258
|
+
* runner awaits the option's blocking commands, so without it a second confirm
|
|
1259
|
+
* would emit a duplicate `onChoiceMade`.
|
|
1260
|
+
*/
|
|
1261
|
+
commit(position) {
|
|
1262
|
+
if (this.paused) return;
|
|
1263
|
+
if (this.mode !== "choosing" || this.confirming) return;
|
|
1264
|
+
const chosen = this.resolved[position];
|
|
1265
|
+
if (!chosen || chosen.disabled) return;
|
|
1266
|
+
this.selected = position;
|
|
1267
|
+
this.confirming = true;
|
|
1268
|
+
const text = this.i18n.t(chosen.option.key, chosen.option.text, this.readView());
|
|
1269
|
+
this.opts.onChoiceMade?.({ index: chosen.index, text });
|
|
1270
|
+
this.runner?.choose(chosen.index);
|
|
1271
|
+
}
|
|
1272
|
+
/** Toggle hold-to-fast-forward; the text channel scales its reveal rate. */
|
|
1273
|
+
setFastForward(on) {
|
|
1274
|
+
this.channels.text.setSpeedMultiplier(on ? this.skipMul : 1);
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Default auto-advance: lines without their own `autoAdvanceMs` advance `ms`
|
|
1278
|
+
* after they finish revealing; `null` turns it off (manual advance). A
|
|
1279
|
+
* per-line `autoAdvanceMs` always overrides this. Toggling it while a line is
|
|
1280
|
+
* already sitting revealed arms/clears its timer immediately.
|
|
1281
|
+
*/
|
|
1282
|
+
setAutoAdvance(ms) {
|
|
1283
|
+
this.autoAdvanceDefault = ms;
|
|
1284
|
+
if (this.mode === "saying" && this.saying?.autoAdvanceMs === void 0 && this.channels.text.isRevealComplete()) {
|
|
1285
|
+
this.autoTimer = ms ?? void 0;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
// ── runner handlers ─────────────────────────────────────────────────────
|
|
1289
|
+
handleSay(step, speaker) {
|
|
1290
|
+
this.mode = "saying";
|
|
1291
|
+
this.saying = step;
|
|
1292
|
+
this.autoTimer = void 0;
|
|
1293
|
+
this.advanceFired = false;
|
|
1294
|
+
this.afterRevealFired = false;
|
|
1295
|
+
this.confirming = false;
|
|
1296
|
+
const view = this.readView();
|
|
1297
|
+
const resolved = this.i18n.t(step.key, step.text, view);
|
|
1298
|
+
const line = {
|
|
1299
|
+
speaker: this.speakerView(speaker, view),
|
|
1300
|
+
text: parseMarkup(resolved),
|
|
1301
|
+
speed: step.speed ?? 1,
|
|
1302
|
+
view: step.view,
|
|
1303
|
+
meta: step.meta,
|
|
1304
|
+
voice: step.voice
|
|
1305
|
+
};
|
|
1306
|
+
this.channels.choices.clear();
|
|
1307
|
+
this.channels.chrome?.setContinueVisible(false);
|
|
1308
|
+
this.channels.chrome?.setNameplate(
|
|
1309
|
+
this.speakerName(speaker, view),
|
|
1310
|
+
speaker?.color
|
|
1311
|
+
);
|
|
1312
|
+
this.channels.avatar?.setSpeaker(speaker);
|
|
1313
|
+
this.channels.avatar?.setExpression(step.expression);
|
|
1314
|
+
this.channels.avatar?.setSpeaking(true);
|
|
1315
|
+
this.channels.avatar?.present?.(line);
|
|
1316
|
+
this.channels.chrome?.present?.(line);
|
|
1317
|
+
this.channels.text.present(line);
|
|
1318
|
+
this.currentPresented = line;
|
|
1319
|
+
for (const ch of this.extras) {
|
|
1320
|
+
try {
|
|
1321
|
+
ch.present?.(line);
|
|
1322
|
+
} catch (error) {
|
|
1323
|
+
this.opts.onError?.("dialogue: channel present() failed", error);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
const plain = stripMarkup(resolved);
|
|
1327
|
+
this.currentLine = { speaker: this.speakerName(speaker, view), text: plain };
|
|
1328
|
+
this.opts.onLine?.({ speaker: this.currentLine.speaker, text: plain });
|
|
1329
|
+
this.applyVisibility();
|
|
1330
|
+
void this.fireLineCommands("show");
|
|
1331
|
+
}
|
|
1332
|
+
handleChoice(step, choices, speaker) {
|
|
1333
|
+
this.mode = "choosing";
|
|
1334
|
+
this.resolved = choices;
|
|
1335
|
+
const firstEnabled = choices.findIndex((c) => !c.disabled);
|
|
1336
|
+
if (firstEnabled < 0) {
|
|
1337
|
+
throw new Error("dialogue: choice step presented with no enabled option");
|
|
1338
|
+
}
|
|
1339
|
+
this.selected = firstEnabled;
|
|
1340
|
+
this.confirming = false;
|
|
1341
|
+
const view = this.readView();
|
|
1342
|
+
const line = {
|
|
1343
|
+
speaker: this.speakerView(speaker, view),
|
|
1344
|
+
text: step.text ? parseMarkup(this.i18n.t(step.key, step.text, view)) : EMPTY_PARSED,
|
|
1345
|
+
speed: 1,
|
|
1346
|
+
view: step.view,
|
|
1347
|
+
meta: step.meta
|
|
1348
|
+
};
|
|
1349
|
+
this.channels.avatar?.setSpeaker(speaker);
|
|
1350
|
+
this.channels.avatar?.setExpression(void 0);
|
|
1351
|
+
this.channels.avatar?.setSpeaking(false);
|
|
1352
|
+
this.channels.avatar?.present?.(line);
|
|
1353
|
+
const ctx = {
|
|
1354
|
+
view: step.view,
|
|
1355
|
+
speaker: line.speaker,
|
|
1356
|
+
prompt: line.text,
|
|
1357
|
+
meta: step.meta
|
|
1358
|
+
};
|
|
1359
|
+
const presenterOwnsPrompt = this.channels.choices.ownsPrompt?.(ctx) ?? false;
|
|
1360
|
+
this.choiceShowsChrome = !presenterOwnsPrompt;
|
|
1361
|
+
this.choiceShowsBody = !presenterOwnsPrompt && Boolean(step.text);
|
|
1362
|
+
this.channels.chrome?.setContinueVisible(false);
|
|
1363
|
+
if (presenterOwnsPrompt) {
|
|
1364
|
+
this.channels.chrome?.present?.(void 0);
|
|
1365
|
+
this.channels.text.clear();
|
|
1366
|
+
} else {
|
|
1367
|
+
this.channels.chrome?.setNameplate(
|
|
1368
|
+
this.speakerName(speaker, view),
|
|
1369
|
+
speaker?.color
|
|
1370
|
+
);
|
|
1371
|
+
this.channels.chrome?.present?.(line);
|
|
1372
|
+
if (step.text) this.channels.text.present(line);
|
|
1373
|
+
else this.channels.text.clear();
|
|
1374
|
+
}
|
|
1375
|
+
const labels = choices.map(
|
|
1376
|
+
(c) => stripMarkup(this.i18n.t(c.option.key, c.option.text, view))
|
|
1377
|
+
);
|
|
1378
|
+
const presented = choices.map((c, i) => ({
|
|
1379
|
+
label: labels[i],
|
|
1380
|
+
meta: c.option.meta,
|
|
1381
|
+
disabled: c.disabled,
|
|
1382
|
+
// i18n-resolve the reason (interpolating {tokens}) only for disabled rows
|
|
1383
|
+
// that carry one; there's no separate i18n key for it.
|
|
1384
|
+
disabledReason: c.disabled && c.option.disabledReason !== void 0 ? stripMarkup(this.i18n.t(void 0, c.option.disabledReason, view)) : void 0
|
|
1385
|
+
}));
|
|
1386
|
+
this.channels.choices.present(presented, ctx);
|
|
1387
|
+
this.channels.choices.highlight(this.selected);
|
|
1388
|
+
this.applyVisibility();
|
|
1389
|
+
this.opts.onChoiceShown?.({ options: labels });
|
|
1390
|
+
}
|
|
1391
|
+
handleCommand(command, ctx) {
|
|
1392
|
+
this.opts.onCommand?.(command, ctx);
|
|
1393
|
+
for (const ch of this.extras) {
|
|
1394
|
+
try {
|
|
1395
|
+
ch.command?.(command, ctx);
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
this.opts.onError?.("dialogue: channel command() failed", error);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
const handler = this.commands[command.type] ?? this.fallbackCommand;
|
|
1401
|
+
return handler?.(command, ctx);
|
|
1402
|
+
}
|
|
1403
|
+
handleEnd() {
|
|
1404
|
+
this.goIdle("ended");
|
|
1405
|
+
this.opts.onEnded?.({ scriptId: this.scriptId });
|
|
1406
|
+
}
|
|
1407
|
+
async handleRevealComplete() {
|
|
1408
|
+
if (this.mode !== "saying") return;
|
|
1409
|
+
const gen = this.generation;
|
|
1410
|
+
this.channels.avatar?.setSpeaking(false);
|
|
1411
|
+
if (!this.afterRevealFired) {
|
|
1412
|
+
this.afterRevealFired = true;
|
|
1413
|
+
await this.fireLineCommands("afterReveal");
|
|
1414
|
+
}
|
|
1415
|
+
if (gen !== this.generation || this.mode !== "saying") return;
|
|
1416
|
+
this.channels.chrome?.setContinueVisible(true);
|
|
1417
|
+
if (this.currentLine) this.opts.onRevealCompleted?.(this.currentLine);
|
|
1418
|
+
const presented = this.currentPresented;
|
|
1419
|
+
if (presented) {
|
|
1420
|
+
for (const ch of this.extras) {
|
|
1421
|
+
try {
|
|
1422
|
+
ch.revealComplete?.(presented);
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
this.opts.onError?.("dialogue: channel revealComplete() failed", error);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
const auto = this.saying?.autoAdvanceMs ?? this.autoAdvanceDefault;
|
|
1429
|
+
if (auto !== null) this.autoTimer = auto;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Fan one reveal beat out — the per-line typewriter stream. Extras (Voice /
|
|
1433
|
+
* CameraEffects / a typewriter-SFX channel) see the WHOLE stream via
|
|
1434
|
+
* `revealBeat?`. A `tick` then reaches the host's `onRevealTick` callback; a
|
|
1435
|
+
* `marker` reaches the avatar channel (which interprets `[expression=…/]`
|
|
1436
|
+
* itself — the Session name-matches nothing) and the host's `onRevealMarker`.
|
|
1437
|
+
*/
|
|
1438
|
+
handleRevealBeat(beat) {
|
|
1439
|
+
for (const ch of this.extras) {
|
|
1440
|
+
try {
|
|
1441
|
+
ch.revealBeat?.(beat);
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
this.opts.onError?.("dialogue: channel revealBeat() failed", error);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (beat.kind === "tick") {
|
|
1447
|
+
this.opts.onRevealTick?.(beat.index);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
this.channels.avatar?.marker?.(beat.marker);
|
|
1451
|
+
this.opts.onRevealMarker?.(beat.marker, beat.viaSkip);
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Fire the current line's commands matching `at`, via the runner's command
|
|
1455
|
+
* pipeline (so `set`/blocking behave identically). While a blocking one is
|
|
1456
|
+
* awaited, `lineBlocked` gates input so the player can't advance through it.
|
|
1457
|
+
* `mode` overrides the runner's run mode (skip() fires the displayed line's
|
|
1458
|
+
* batches in skip mode).
|
|
1459
|
+
*/
|
|
1460
|
+
async fireLineCommands(at, mode) {
|
|
1461
|
+
const all = this.saying?.commands;
|
|
1462
|
+
if (!all || !this.runner) return;
|
|
1463
|
+
const batch = all.filter((c) => (c.at ?? "show") === at);
|
|
1464
|
+
if (batch.length === 0) return;
|
|
1465
|
+
const gen = this.generation;
|
|
1466
|
+
const blocking = batch.some((c) => c.blocking);
|
|
1467
|
+
if (blocking) this.blockedCount++;
|
|
1468
|
+
try {
|
|
1469
|
+
await this.runner.runCommands(batch, mode);
|
|
1470
|
+
} finally {
|
|
1471
|
+
if (blocking && gen === this.generation) this.blockedCount--;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
speakerName(speaker, view) {
|
|
1475
|
+
if (!speaker) return void 0;
|
|
1476
|
+
return this.i18n.t(speaker.nameKey, speaker.name, view);
|
|
1477
|
+
}
|
|
1478
|
+
speakerView(speaker, view) {
|
|
1479
|
+
if (!speaker) return void 0;
|
|
1480
|
+
return {
|
|
1481
|
+
id: speaker.id,
|
|
1482
|
+
name: this.speakerName(speaker, view),
|
|
1483
|
+
color: speaker.color
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
// src/core/channels/voice.ts
|
|
1489
|
+
function createVoiceChannel(opts) {
|
|
1490
|
+
const { play, onSkip = "cut", livenessMs, onError } = opts;
|
|
1491
|
+
const pauseClip = opts.pauseWithConversation ?? true;
|
|
1492
|
+
let active;
|
|
1493
|
+
let done = true;
|
|
1494
|
+
let startToken = 0;
|
|
1495
|
+
let elapsed = 0;
|
|
1496
|
+
const stop = /* @__PURE__ */ __name(() => {
|
|
1497
|
+
active?.stop();
|
|
1498
|
+
active = void 0;
|
|
1499
|
+
done = true;
|
|
1500
|
+
}, "stop");
|
|
1501
|
+
return {
|
|
1502
|
+
present(line) {
|
|
1503
|
+
active?.stop();
|
|
1504
|
+
active = void 0;
|
|
1505
|
+
startToken++;
|
|
1506
|
+
elapsed = 0;
|
|
1507
|
+
const id = line.voice;
|
|
1508
|
+
if (id === void 0) {
|
|
1509
|
+
done = true;
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
done = false;
|
|
1513
|
+
const token = startToken;
|
|
1514
|
+
try {
|
|
1515
|
+
active = play(id, () => {
|
|
1516
|
+
if (token !== startToken) return;
|
|
1517
|
+
done = true;
|
|
1518
|
+
active = void 0;
|
|
1519
|
+
});
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
done = true;
|
|
1522
|
+
throw error;
|
|
1523
|
+
}
|
|
1524
|
+
},
|
|
1525
|
+
completeReveal() {
|
|
1526
|
+
if (onSkip === "ring") return;
|
|
1527
|
+
stop();
|
|
1528
|
+
},
|
|
1529
|
+
setPaused(paused) {
|
|
1530
|
+
if (!pauseClip) return;
|
|
1531
|
+
if (paused) active?.pause?.();
|
|
1532
|
+
else active?.resume?.();
|
|
1533
|
+
},
|
|
1534
|
+
update(dt) {
|
|
1535
|
+
if (done || !livenessMs) return;
|
|
1536
|
+
elapsed += dt;
|
|
1537
|
+
if (elapsed >= livenessMs) {
|
|
1538
|
+
done = true;
|
|
1539
|
+
onError?.(
|
|
1540
|
+
`voice clip exceeded the ${livenessMs}ms liveness budget without reporting its end; releasing the auto-advance gate`,
|
|
1541
|
+
new Error("voice clip liveness cap")
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
},
|
|
1545
|
+
clear() {
|
|
1546
|
+
stop();
|
|
1547
|
+
},
|
|
1548
|
+
dispose() {
|
|
1549
|
+
stop();
|
|
1550
|
+
},
|
|
1551
|
+
isRevealComplete() {
|
|
1552
|
+
return done;
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
__name(createVoiceChannel, "createVoiceChannel");
|
|
1557
|
+
|
|
1558
|
+
// src/DialogueController.ts
|
|
1559
|
+
import { Component, LoggerKey } from "@yagejs/core";
|
|
1560
|
+
import { InputManagerKey } from "@yagejs/input";
|
|
1561
|
+
|
|
1562
|
+
// src/input/InputBinding.ts
|
|
1563
|
+
var DEFAULT_ACTIONS = {
|
|
1564
|
+
advance: ["interact"],
|
|
1565
|
+
speed: ["attack"],
|
|
1566
|
+
up: ["move-up"],
|
|
1567
|
+
down: ["move-down"]
|
|
1568
|
+
};
|
|
1569
|
+
var FULL_ACTIONS = {
|
|
1570
|
+
...DEFAULT_ACTIONS,
|
|
1571
|
+
skip: ["skip"]
|
|
1572
|
+
};
|
|
1573
|
+
var CompositeInputBinding = class {
|
|
1574
|
+
constructor(bindings) {
|
|
1575
|
+
this.bindings = bindings;
|
|
1576
|
+
}
|
|
1577
|
+
bindings;
|
|
1578
|
+
static {
|
|
1579
|
+
__name(this, "CompositeInputBinding");
|
|
1580
|
+
}
|
|
1581
|
+
bind(input, session) {
|
|
1582
|
+
for (const b of this.bindings) b.bind(input, session);
|
|
1583
|
+
}
|
|
1584
|
+
poll() {
|
|
1585
|
+
for (const b of this.bindings) b.poll();
|
|
1586
|
+
}
|
|
1587
|
+
dispose() {
|
|
1588
|
+
for (const b of this.bindings) b.dispose?.();
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
var KeyboardInputBinding = class {
|
|
1592
|
+
/**
|
|
1593
|
+
* @param skipHoldMs Hold the `skip` action this long before it fires (the
|
|
1594
|
+
* classic "hold to skip" confirm). `0` (default) fires on press.
|
|
1595
|
+
*/
|
|
1596
|
+
constructor(actions = DEFAULT_ACTIONS, skipHoldMs = 0) {
|
|
1597
|
+
this.actions = actions;
|
|
1598
|
+
this.skipHoldMs = skipHoldMs;
|
|
1599
|
+
}
|
|
1600
|
+
actions;
|
|
1601
|
+
skipHoldMs;
|
|
1602
|
+
static {
|
|
1603
|
+
__name(this, "KeyboardInputBinding");
|
|
1604
|
+
}
|
|
1605
|
+
input;
|
|
1606
|
+
session;
|
|
1607
|
+
/** Latch so a held skip fires once per hold, not every frame past threshold. */
|
|
1608
|
+
skipFired = false;
|
|
1609
|
+
bind(input, session) {
|
|
1610
|
+
this.input = input;
|
|
1611
|
+
this.session = session;
|
|
1612
|
+
}
|
|
1613
|
+
poll() {
|
|
1614
|
+
const { input, session } = this;
|
|
1615
|
+
if (!input || !session) return;
|
|
1616
|
+
session.setFastForward(held(input, this.actions.speed));
|
|
1617
|
+
this.pollSkip(input, session);
|
|
1618
|
+
if (justPressed(input, this.actions.advance)) session.advance();
|
|
1619
|
+
if (justPressed(input, this.actions.up)) session.moveSelection(-1);
|
|
1620
|
+
else if (justPressed(input, this.actions.down)) session.moveSelection(1);
|
|
1621
|
+
}
|
|
1622
|
+
/** Fire skip once the action has been held `skipHoldMs` (hold-to-confirm),
|
|
1623
|
+
* re-arming only after it's released. */
|
|
1624
|
+
pollSkip(input, session) {
|
|
1625
|
+
const skip = this.actions.skip;
|
|
1626
|
+
if (!skip) return;
|
|
1627
|
+
const ready = skip.some(
|
|
1628
|
+
(a) => input.isPressed(a) && input.isHeldFor(a, this.skipHoldMs)
|
|
1629
|
+
);
|
|
1630
|
+
if (ready) {
|
|
1631
|
+
if (!this.skipFired) {
|
|
1632
|
+
session.skip();
|
|
1633
|
+
this.skipFired = true;
|
|
1634
|
+
}
|
|
1635
|
+
} else if (!held(input, skip)) {
|
|
1636
|
+
this.skipFired = false;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
function justPressed(input, actions) {
|
|
1641
|
+
return actions.some((a) => input.isJustPressed(a));
|
|
1642
|
+
}
|
|
1643
|
+
__name(justPressed, "justPressed");
|
|
1644
|
+
function held(input, actions) {
|
|
1645
|
+
return actions.some((a) => input.isPressed(a));
|
|
1646
|
+
}
|
|
1647
|
+
__name(held, "held");
|
|
1648
|
+
var PointerInputBinding = class {
|
|
1649
|
+
static {
|
|
1650
|
+
__name(this, "PointerInputBinding");
|
|
1651
|
+
}
|
|
1652
|
+
input;
|
|
1653
|
+
session;
|
|
1654
|
+
// Explicit `| undefined` so `dispose()` can null it (exactOptionalPropertyTypes).
|
|
1655
|
+
unsub;
|
|
1656
|
+
/** A primary-button press happened since the last poll (consumed in poll). */
|
|
1657
|
+
clicked = false;
|
|
1658
|
+
// Explicit `| undefined` (not `?:`) so the ctor can assign the possibly-
|
|
1659
|
+
// undefined argument under `exactOptionalPropertyTypes`.
|
|
1660
|
+
choices;
|
|
1661
|
+
/** Pointer position at the last hover hit-test, so an unmoved pointer
|
|
1662
|
+
* doesn't re-run the hit-test every frame. */
|
|
1663
|
+
lastX = Number.NaN;
|
|
1664
|
+
lastY = Number.NaN;
|
|
1665
|
+
/** Whether the previous poll saw a choice up (a fresh choice set must be
|
|
1666
|
+
* hit-tested even under a stationary pointer). */
|
|
1667
|
+
wasChoosing = false;
|
|
1668
|
+
constructor(choices) {
|
|
1669
|
+
this.choices = choices;
|
|
1670
|
+
}
|
|
1671
|
+
bind(input, session) {
|
|
1672
|
+
this.unsub?.();
|
|
1673
|
+
this.input = input;
|
|
1674
|
+
this.session = session;
|
|
1675
|
+
this.unsub = input.onPointerDown((info) => {
|
|
1676
|
+
if (info.button === 0) this.clicked = true;
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
/** Pointer position in the choice presenter's coordinate space. */
|
|
1680
|
+
pointer(input) {
|
|
1681
|
+
return this.choices?.pointerSpace === "world" ? input.getPointerPosition() : input.getPointerScreenPosition();
|
|
1682
|
+
}
|
|
1683
|
+
poll() {
|
|
1684
|
+
const { input, session } = this;
|
|
1685
|
+
if (!input || !session) return;
|
|
1686
|
+
const choosing = session.isChoosing();
|
|
1687
|
+
if (choosing && this.choices?.choiceAtPoint) {
|
|
1688
|
+
const p = this.pointer(input);
|
|
1689
|
+
if (!this.wasChoosing || p.x !== this.lastX || p.y !== this.lastY) {
|
|
1690
|
+
this.lastX = p.x;
|
|
1691
|
+
this.lastY = p.y;
|
|
1692
|
+
const hovered = this.choices.choiceAtPoint(p.x, p.y);
|
|
1693
|
+
if (hovered !== void 0) session.selectAt(hovered);
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
this.wasChoosing = choosing;
|
|
1697
|
+
const clicked = this.clicked;
|
|
1698
|
+
this.clicked = false;
|
|
1699
|
+
if (!clicked) return;
|
|
1700
|
+
if (choosing) {
|
|
1701
|
+
const p = this.pointer(input);
|
|
1702
|
+
const hit = this.choices?.choiceAtPoint?.(p.x, p.y);
|
|
1703
|
+
if (hit !== void 0) session.confirmAt(hit);
|
|
1704
|
+
} else {
|
|
1705
|
+
session.advance();
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
dispose() {
|
|
1709
|
+
this.unsub?.();
|
|
1710
|
+
this.unsub = void 0;
|
|
1711
|
+
}
|
|
1712
|
+
};
|
|
1713
|
+
function fullControls(choices, options = {}) {
|
|
1714
|
+
const { actions = FULL_ACTIONS, skipHoldMs = 0 } = options;
|
|
1715
|
+
return new CompositeInputBinding([
|
|
1716
|
+
new KeyboardInputBinding(actions, skipHoldMs),
|
|
1717
|
+
new PointerInputBinding(choices)
|
|
1718
|
+
]);
|
|
1719
|
+
}
|
|
1720
|
+
__name(fullControls, "fullControls");
|
|
1721
|
+
|
|
1722
|
+
// src/events.ts
|
|
1723
|
+
import { defineEvent } from "@yagejs/core";
|
|
1724
|
+
var DialogueStartedEvent = defineEvent("dialogue:started");
|
|
1725
|
+
var DialogueLineEvent = defineEvent("dialogue:line");
|
|
1726
|
+
var DialogueChoiceShownEvent = defineEvent(
|
|
1727
|
+
"dialogue:choice-shown"
|
|
1728
|
+
);
|
|
1729
|
+
var DialogueChoiceMadeEvent = defineEvent(
|
|
1730
|
+
"dialogue:choice-made"
|
|
1731
|
+
);
|
|
1732
|
+
var DialogueCommandEvent = defineEvent(
|
|
1733
|
+
"dialogue:command"
|
|
1734
|
+
);
|
|
1735
|
+
var DialogueEndedEvent = defineEvent("dialogue:ended");
|
|
1736
|
+
var DialogueRevealCompletedEvent = defineEvent("dialogue:reveal-completed");
|
|
1737
|
+
var DialogueSelectionChangedEvent = defineEvent(
|
|
1738
|
+
"dialogue:selection-changed"
|
|
1739
|
+
);
|
|
1740
|
+
var DialogueSkipUsedEvent = defineEvent(
|
|
1741
|
+
"dialogue:skip-used"
|
|
1742
|
+
);
|
|
1743
|
+
var DialogueAutoAdvanceEvent = defineEvent(
|
|
1744
|
+
"dialogue:auto-advance"
|
|
1745
|
+
);
|
|
1746
|
+
var DialogueRevealMarkerEvent = defineEvent("dialogue:reveal-marker");
|
|
1747
|
+
|
|
1748
|
+
// src/DialogueController.ts
|
|
1749
|
+
var DialogueController = class extends Component {
|
|
1750
|
+
constructor(opts) {
|
|
1751
|
+
super();
|
|
1752
|
+
this.opts = opts;
|
|
1753
|
+
this.binding = opts.input ?? new KeyboardInputBinding();
|
|
1754
|
+
}
|
|
1755
|
+
opts;
|
|
1756
|
+
static {
|
|
1757
|
+
__name(this, "DialogueController");
|
|
1758
|
+
}
|
|
1759
|
+
input = this.service(InputManagerKey);
|
|
1760
|
+
binding;
|
|
1761
|
+
session;
|
|
1762
|
+
/** Captured at onAdd (the scene is gone by the time a stale play() arrives). */
|
|
1763
|
+
logger;
|
|
1764
|
+
/** Set by onDestroy — the presenters are disposed, so play() must refuse. */
|
|
1765
|
+
destroyed = false;
|
|
1766
|
+
/** Input focus. When false, `update()` keeps pumping the session (an
|
|
1767
|
+
* ambient conversation stays alive) but the binding is NOT polled, so this
|
|
1768
|
+
* instance doesn't consume device input. NOT `Component.enabled` (which would
|
|
1769
|
+
* also freeze the session). */
|
|
1770
|
+
inputEnabled = true;
|
|
1771
|
+
/** Pause. Mirrors the session's pause so the binding poll is also gated
|
|
1772
|
+
* while frozen — a paused conversation neither updates nor consumes input.
|
|
1773
|
+
* Also the source of truth re-applied to the session in `onAdd` when a host
|
|
1774
|
+
* set it before the component was added (the session didn't exist yet). */
|
|
1775
|
+
paused = false;
|
|
1776
|
+
/** Hidden. Mirrors the session's hide so a `setHidden` issued before the
|
|
1777
|
+
* component was added isn't lost — it's re-applied once the session exists. */
|
|
1778
|
+
hidden = false;
|
|
1779
|
+
/** Disposers for every registered extra channel (ctor `channels` + live
|
|
1780
|
+
* `addChannel`). `onDestroy` runs them all — each idempotent — to unregister
|
|
1781
|
+
* and dispose (unmounting the Mountable ones). */
|
|
1782
|
+
channelDisposers = /* @__PURE__ */ new Set();
|
|
1783
|
+
onAdd() {
|
|
1784
|
+
this.logger = this.context.tryResolve(LoggerKey);
|
|
1785
|
+
const warn = /* @__PURE__ */ __name((message) => this.logger?.warn("dialogue", message), "warn");
|
|
1786
|
+
this.opts.chrome.mount(this.scene);
|
|
1787
|
+
this.opts.choices.mount(this.scene);
|
|
1788
|
+
this.opts.avatar?.mount(this.scene);
|
|
1789
|
+
this.opts.text.mount(this.scene);
|
|
1790
|
+
this.opts.chrome.setDiagnostics?.(warn);
|
|
1791
|
+
this.opts.text.setDiagnostics?.(warn);
|
|
1792
|
+
this.opts.choices.setDiagnostics?.(warn);
|
|
1793
|
+
this.session = new DialogueSession(
|
|
1794
|
+
{
|
|
1795
|
+
text: this.opts.text,
|
|
1796
|
+
choices: this.opts.choices,
|
|
1797
|
+
avatar: this.opts.avatar,
|
|
1798
|
+
chrome: this.opts.chrome
|
|
1799
|
+
},
|
|
1800
|
+
{
|
|
1801
|
+
i18n: this.opts.i18n,
|
|
1802
|
+
skipMultiplier: this.opts.skipMultiplier,
|
|
1803
|
+
// Controller-installed environment — persists across plays.
|
|
1804
|
+
storage: this.opts.storage,
|
|
1805
|
+
functions: this.opts.functions,
|
|
1806
|
+
commands: this.opts.commands,
|
|
1807
|
+
fallbackCommand: this.opts.fallbackCommand,
|
|
1808
|
+
onStarted: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueStartedEvent, e), "onStarted"),
|
|
1809
|
+
onLine: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueLineEvent, e), "onLine"),
|
|
1810
|
+
onChoiceShown: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueChoiceShownEvent, { options: e.options }), "onChoiceShown"),
|
|
1811
|
+
onChoiceMade: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueChoiceMadeEvent, e), "onChoiceMade"),
|
|
1812
|
+
// Observation only — the `commands` map does the work; this mirrors
|
|
1813
|
+
// every non-built-in command onto the scene event bus.
|
|
1814
|
+
onCommand: /* @__PURE__ */ __name((command, ctx) => this.entity.emit(DialogueCommandEvent, { command, mode: ctx.mode }), "onCommand"),
|
|
1815
|
+
// Route non-fatal runtime diagnostics (e.g. a `set` to a read-only cell)
|
|
1816
|
+
// through the engine logger rather than crashing or silently dropping.
|
|
1817
|
+
onError: /* @__PURE__ */ __name((message) => warn(message), "onError"),
|
|
1818
|
+
onEnded: /* @__PURE__ */ __name((e) => {
|
|
1819
|
+
this.entity.emit(DialogueEndedEvent, e);
|
|
1820
|
+
this.opts.onEnded?.();
|
|
1821
|
+
}, "onEnded"),
|
|
1822
|
+
// Observation events — the controller is the one canonical path that
|
|
1823
|
+
// turns the session's callbacks into entity→scene events (no matching
|
|
1824
|
+
// controller callback options).
|
|
1825
|
+
onRevealCompleted: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueRevealCompletedEvent, e), "onRevealCompleted"),
|
|
1826
|
+
onSelectionChanged: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueSelectionChangedEvent, e), "onSelectionChanged"),
|
|
1827
|
+
onSkipUsed: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueSkipUsedEvent, e), "onSkipUsed"),
|
|
1828
|
+
onAutoAdvance: /* @__PURE__ */ __name((e) => this.entity.emit(DialogueAutoAdvanceEvent, e), "onAutoAdvance"),
|
|
1829
|
+
// Inline markers fan to an entity event; per-grapheme ticks stay a direct
|
|
1830
|
+
// callback (forwarded verbatim — undefined when the host wires none).
|
|
1831
|
+
onRevealMarker: /* @__PURE__ */ __name((marker, viaSkip) => this.entity.emit(DialogueRevealMarkerEvent, { marker, viaSkip }), "onRevealMarker"),
|
|
1832
|
+
onRevealTick: this.opts.onRevealTick
|
|
1833
|
+
}
|
|
1834
|
+
);
|
|
1835
|
+
this.binding.bind(this.input, this.session);
|
|
1836
|
+
if (this.paused) this.session.setPaused(true);
|
|
1837
|
+
if (this.hidden) this.session.setHidden(true);
|
|
1838
|
+
for (const ch of this.opts.channels ?? []) this.addChannel(ch);
|
|
1839
|
+
}
|
|
1840
|
+
onDestroy() {
|
|
1841
|
+
this.destroyed = true;
|
|
1842
|
+
this.session?.stop();
|
|
1843
|
+
for (const dispose of [...this.channelDisposers]) dispose();
|
|
1844
|
+
this.binding.dispose?.();
|
|
1845
|
+
this.opts.text.dispose();
|
|
1846
|
+
this.opts.choices.dispose();
|
|
1847
|
+
this.opts.chrome.dispose();
|
|
1848
|
+
this.opts.avatar?.dispose();
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Begin a conversation. `play(script)` is **content-only** — storage,
|
|
1852
|
+
* functions, and commands are installed on the controller. `overrides` layers
|
|
1853
|
+
* per-conversation specifics on top (a scoped `storage`, extra
|
|
1854
|
+
* `functions`/`commands`). Returns a {@link DialogueHandle} for live `setVar` /
|
|
1855
|
+
* `getVars`, or `undefined` if the controller was removed.
|
|
1856
|
+
*/
|
|
1857
|
+
play(script, overrides) {
|
|
1858
|
+
if (this.destroyed) {
|
|
1859
|
+
this.logger?.warn(
|
|
1860
|
+
"dialogue",
|
|
1861
|
+
"DialogueController.play() ignored: the component has been removed/destroyed.",
|
|
1862
|
+
{ scriptId: script.id }
|
|
1863
|
+
);
|
|
1864
|
+
return void 0;
|
|
1865
|
+
}
|
|
1866
|
+
if (!this.session) {
|
|
1867
|
+
throw new Error(
|
|
1868
|
+
"DialogueController.play() called before the component was added to an entity (onAdd has not run yet)."
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
return this.session.play(script, overrides);
|
|
1872
|
+
}
|
|
1873
|
+
isActive() {
|
|
1874
|
+
return this.session?.isActive() ?? false;
|
|
1875
|
+
}
|
|
1876
|
+
/** Abandon the current conversation and reset to idle. */
|
|
1877
|
+
stop() {
|
|
1878
|
+
this.session?.stop();
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Register an extra channel live — Voice / Shop / CameraEffects / History.
|
|
1882
|
+
* Mounts it if it needs the scene ({@link Mountable}), hands it to the session
|
|
1883
|
+
* (where it joins the cross-cutting stream and can gate auto-advance), and
|
|
1884
|
+
* returns a disposer that unregisters + disposes it. The `channels` ctor option
|
|
1885
|
+
* registers a bundle the same way at mount. Returns a no-op disposer if the
|
|
1886
|
+
* controller was destroyed; **throws** if called before the component is added
|
|
1887
|
+
* to an entity (use the `channels` ctor option to pre-wire a channel) — mirrors
|
|
1888
|
+
* {@link play}.
|
|
1889
|
+
*/
|
|
1890
|
+
addChannel(ch) {
|
|
1891
|
+
if (this.destroyed) {
|
|
1892
|
+
this.logger?.warn(
|
|
1893
|
+
"dialogue",
|
|
1894
|
+
"DialogueController.addChannel() ignored: the component has been removed/destroyed."
|
|
1895
|
+
);
|
|
1896
|
+
return () => {
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
if (!this.session) {
|
|
1900
|
+
throw new Error(
|
|
1901
|
+
"DialogueController.addChannel() called before the component was added to an entity (onAdd has not run yet). Use the `channels` constructor option to pre-wire a channel."
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
if (isMountable(ch)) {
|
|
1905
|
+
try {
|
|
1906
|
+
ch.mount(this.scene);
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
this.logger?.warn(
|
|
1909
|
+
"dialogue",
|
|
1910
|
+
`extra channel mount() failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
const unregister = this.session.addChannel(ch);
|
|
1915
|
+
const dispose = /* @__PURE__ */ __name(() => {
|
|
1916
|
+
if (!this.channelDisposers.delete(dispose)) return;
|
|
1917
|
+
unregister();
|
|
1918
|
+
}, "dispose");
|
|
1919
|
+
this.channelDisposers.add(dispose);
|
|
1920
|
+
return dispose;
|
|
1921
|
+
}
|
|
1922
|
+
/** Fast-forward the current section to the next choice or the end. */
|
|
1923
|
+
skip() {
|
|
1924
|
+
this.session?.skip();
|
|
1925
|
+
}
|
|
1926
|
+
/**
|
|
1927
|
+
* Auto-advance lines after they finish revealing (`ms`), or `null` to disable
|
|
1928
|
+
* (manual advance). A per-line `autoAdvanceMs` still overrides this. Toggle it
|
|
1929
|
+
* live for a VN-style "auto" control.
|
|
1930
|
+
*/
|
|
1931
|
+
setAutoAdvance(ms) {
|
|
1932
|
+
this.session?.setAutoAdvance(ms);
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Hide or show the whole dialogue UI without ending the conversation —
|
|
1936
|
+
* for a cutscene takeover (`setHidden(true)` while the camera pans, then
|
|
1937
|
+
* `setHidden(false)` to restore the exact line + caret). Purely visual; the
|
|
1938
|
+
* conversation keeps its state. **Persistent**: it survives `stop()`/`play()`,
|
|
1939
|
+
* so a host that hides and forgets to unhide stays hidden.
|
|
1940
|
+
*/
|
|
1941
|
+
setHidden(hidden) {
|
|
1942
|
+
this.hidden = hidden;
|
|
1943
|
+
this.session?.setHidden(hidden);
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Freeze or resume the conversation — a pause menu. While paused the
|
|
1947
|
+
* reveal, auto-advance, caret blink, and avatar anim all halt, input is inert,
|
|
1948
|
+
* and no state is lost (an in-flight blocking command keeps running). Also
|
|
1949
|
+
* gates this controller's input binding so a frozen conversation consumes no
|
|
1950
|
+
* device input. Does NOT block host-driven `handle.setVar` / storage writes.
|
|
1951
|
+
*/
|
|
1952
|
+
setPaused(paused) {
|
|
1953
|
+
this.paused = paused;
|
|
1954
|
+
this.session?.setPaused(paused);
|
|
1955
|
+
}
|
|
1956
|
+
/**
|
|
1957
|
+
* Set whether this controller consumes device input — the focus seam for
|
|
1958
|
+
* the multi-instance story. `setInputEnabled(false)` keeps the conversation
|
|
1959
|
+
* fully alive (it still updates, reveals, auto-advances) but stops polling its
|
|
1960
|
+
* binding, so an ambient conversation doesn't steal the advance key. Switch
|
|
1961
|
+
* focus between two conversations with `a.setInputEnabled(true);
|
|
1962
|
+
* b.setInputEnabled(false)`. (YAGE input is non-consuming, so two *enabled*
|
|
1963
|
+
* controllers both advance on one press — focus is the game's policy.)
|
|
1964
|
+
*/
|
|
1965
|
+
setInputEnabled(enabled) {
|
|
1966
|
+
this.inputEnabled = enabled;
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* Primary action, host-driven (the input-agnostic seam): while saying,
|
|
1970
|
+
* reveal-all if still typing else advance to the next line; while choosing,
|
|
1971
|
+
* confirm the highlighted option. Lets a host (cutscene script, custom input,
|
|
1972
|
+
* or a test) drive the conversation without synthesising device input — the
|
|
1973
|
+
* same call the default {@link InputBinding} makes.
|
|
1974
|
+
*/
|
|
1975
|
+
advance() {
|
|
1976
|
+
this.session?.advance();
|
|
1977
|
+
}
|
|
1978
|
+
/** Move the choice cursor by `delta` (wraps). No-op outside a choice. */
|
|
1979
|
+
moveSelection(delta) {
|
|
1980
|
+
this.session?.moveSelection(delta);
|
|
1981
|
+
}
|
|
1982
|
+
/** Commit a choice by its original option index. No-op outside a choice. */
|
|
1983
|
+
choose(optionIndex) {
|
|
1984
|
+
this.session?.choose(optionIndex);
|
|
1985
|
+
}
|
|
1986
|
+
/** True while a choice is being presented. */
|
|
1987
|
+
isChoosing() {
|
|
1988
|
+
return this.session?.isChoosing() ?? false;
|
|
1989
|
+
}
|
|
1990
|
+
/** Side-effect-free lookahead of the lines a node would show. */
|
|
1991
|
+
preview(nodeId) {
|
|
1992
|
+
return this.session?.preview(nodeId) ?? [];
|
|
1993
|
+
}
|
|
1994
|
+
update(dt) {
|
|
1995
|
+
this.session?.update(dt);
|
|
1996
|
+
if (this.inputEnabled && !this.paused) this.binding.poll();
|
|
1997
|
+
}
|
|
1998
|
+
};
|
|
1999
|
+
function isMountable(ch) {
|
|
2000
|
+
return typeof ch.mount === "function";
|
|
2001
|
+
}
|
|
2002
|
+
__name(isMountable, "isMountable");
|
|
2003
|
+
export {
|
|
2004
|
+
CompositeInputBinding,
|
|
2005
|
+
DEFAULT_ACTIONS,
|
|
2006
|
+
DialogueAutoAdvanceEvent,
|
|
2007
|
+
DialogueChoiceMadeEvent,
|
|
2008
|
+
DialogueChoiceShownEvent,
|
|
2009
|
+
DialogueCommandEvent,
|
|
2010
|
+
DialogueController,
|
|
2011
|
+
DialogueEndedEvent,
|
|
2012
|
+
DialogueExprError,
|
|
2013
|
+
DialogueLineEvent,
|
|
2014
|
+
DialoguePlayError,
|
|
2015
|
+
DialogueRevealCompletedEvent,
|
|
2016
|
+
DialogueRevealMarkerEvent,
|
|
2017
|
+
DialogueRunner,
|
|
2018
|
+
DialogueScriptError,
|
|
2019
|
+
DialogueSelectionChangedEvent,
|
|
2020
|
+
DialogueSession,
|
|
2021
|
+
DialogueSkipUsedEvent,
|
|
2022
|
+
DialogueStartedEvent,
|
|
2023
|
+
EMPTY_PARSED,
|
|
2024
|
+
FULL_ACTIONS,
|
|
2025
|
+
IdentityI18n,
|
|
2026
|
+
KeyboardInputBinding,
|
|
2027
|
+
LineReveal,
|
|
2028
|
+
MemoryVariableStorage,
|
|
2029
|
+
PointerInputBinding,
|
|
2030
|
+
cells,
|
|
2031
|
+
compose,
|
|
2032
|
+
createVoiceChannel,
|
|
2033
|
+
defineScript,
|
|
2034
|
+
evalCondition,
|
|
2035
|
+
evaluate,
|
|
2036
|
+
fullControls,
|
|
2037
|
+
interpolate,
|
|
2038
|
+
isExpr,
|
|
2039
|
+
loadCompact,
|
|
2040
|
+
loadScript,
|
|
2041
|
+
materialize,
|
|
2042
|
+
parseCompact,
|
|
2043
|
+
parseExpr,
|
|
2044
|
+
parseMarkup,
|
|
2045
|
+
splitGraphemes,
|
|
2046
|
+
stripMarkup
|
|
2047
|
+
};
|
|
2048
|
+
//# sourceMappingURL=index.js.map
|