yarn-spinner-runner-ts 0.1.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/README.md +180 -0
- package/dist/compile/compiler.d.ts +9 -0
- package/dist/compile/compiler.js +172 -0
- package/dist/compile/compiler.js.map +1 -0
- package/dist/compile/ir.d.ts +47 -0
- package/dist/compile/ir.js +2 -0
- package/dist/compile/ir.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/model/ast.d.ts +72 -0
- package/dist/model/ast.js +2 -0
- package/dist/model/ast.js.map +1 -0
- package/dist/parse/lexer.d.ts +7 -0
- package/dist/parse/lexer.js +78 -0
- package/dist/parse/lexer.js.map +1 -0
- package/dist/parse/parser.d.ts +4 -0
- package/dist/parse/parser.js +433 -0
- package/dist/parse/parser.js.map +1 -0
- package/dist/runtime/commands.d.ts +31 -0
- package/dist/runtime/commands.js +157 -0
- package/dist/runtime/commands.js.map +1 -0
- package/dist/runtime/evaluator.d.ts +52 -0
- package/dist/runtime/evaluator.js +309 -0
- package/dist/runtime/evaluator.js.map +1 -0
- package/dist/runtime/results.d.ts +22 -0
- package/dist/runtime/results.js +2 -0
- package/dist/runtime/results.js.map +1 -0
- package/dist/runtime/runner.d.ts +63 -0
- package/dist/runtime/runner.js +456 -0
- package/dist/runtime/runner.js.map +1 -0
- package/dist/tests/full_featured.test.d.ts +1 -0
- package/dist/tests/full_featured.test.js +130 -0
- package/dist/tests/full_featured.test.js.map +1 -0
- package/dist/tests/index.test.d.ts +1 -0
- package/dist/tests/index.test.js +30 -0
- package/dist/tests/index.test.js.map +1 -0
- package/dist/tests/jump_detour.test.d.ts +1 -0
- package/dist/tests/jump_detour.test.js +47 -0
- package/dist/tests/jump_detour.test.js.map +1 -0
- package/dist/tests/nodes_lines.test.d.ts +1 -0
- package/dist/tests/nodes_lines.test.js +23 -0
- package/dist/tests/nodes_lines.test.js.map +1 -0
- package/dist/tests/once.test.d.ts +1 -0
- package/dist/tests/once.test.js +29 -0
- package/dist/tests/once.test.js.map +1 -0
- package/dist/tests/options.test.d.ts +1 -0
- package/dist/tests/options.test.js +32 -0
- package/dist/tests/options.test.js.map +1 -0
- package/dist/tests/variables_flow_cmds.test.d.ts +1 -0
- package/dist/tests/variables_flow_cmds.test.js +30 -0
- package/dist/tests/variables_flow_cmds.test.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/commands.md +21 -0
- package/docs/compatibility-checklist.md +77 -0
- package/docs/css-attribute.md +47 -0
- package/docs/detour.md +24 -0
- package/docs/enums.md +25 -0
- package/docs/flow-control.md +25 -0
- package/docs/functions.md +20 -0
- package/docs/jumps.md +24 -0
- package/docs/line-groups.md +21 -0
- package/docs/lines-nodes-and-options.md +30 -0
- package/docs/logic-and-variables.md +25 -0
- package/docs/markup.md +19 -0
- package/docs/node-groups.md +13 -0
- package/docs/once.md +21 -0
- package/docs/options.md +45 -0
- package/docs/saliency.md +25 -0
- package/docs/scenes-actors-setup.md +195 -0
- package/docs/scenes.md +64 -0
- package/docs/shadow-lines.md +18 -0
- package/docs/smart-variables.md +19 -0
- package/docs/storylets-and-saliency-a-primer.md +14 -0
- package/docs/tags-metadata.md +18 -0
- package/eslint.config.cjs +33 -0
- package/examples/browser/README.md +40 -0
- package/examples/browser/index.html +23 -0
- package/examples/browser/main.tsx +16 -0
- package/examples/browser/vite.config.ts +22 -0
- package/examples/react/DialogueExample.tsx +2 -0
- package/examples/react/DialogueView.tsx +2 -0
- package/examples/react/useYarnRunner.tsx +2 -0
- package/examples/scenes/scenes.yaml +10 -0
- package/examples/yarn/full_featured.yarn +43 -0
- package/package.json +55 -0
- package/src/compile/compiler.ts +183 -0
- package/src/compile/ir.ts +28 -0
- package/src/index.ts +17 -0
- package/src/model/ast.ts +93 -0
- package/src/parse/lexer.ts +108 -0
- package/src/parse/parser.ts +435 -0
- package/src/react/DialogueExample.tsx +149 -0
- package/src/react/DialogueScene.tsx +107 -0
- package/src/react/DialogueView.tsx +160 -0
- package/src/react/dialogue.css +181 -0
- package/src/react/useYarnRunner.tsx +33 -0
- package/src/runtime/commands.ts +183 -0
- package/src/runtime/evaluator.ts +327 -0
- package/src/runtime/results.ts +27 -0
- package/src/runtime/runner.ts +480 -0
- package/src/scene/parser.ts +83 -0
- package/src/scene/types.ts +17 -0
- package/src/tests/full_featured.test.ts +131 -0
- package/src/tests/index.test.ts +34 -0
- package/src/tests/jump_detour.test.ts +47 -0
- package/src/tests/nodes_lines.test.ts +27 -0
- package/src/tests/once.test.ts +32 -0
- package/src/tests/options.test.ts +34 -0
- package/src/tests/variables_flow_cmds.test.ts +33 -0
- package/src/types.ts +4 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import type { IRProgram, IRInstruction, IRNode, IRNodeGroup } from "../compile/ir";
|
|
2
|
+
import type { RuntimeResult } from "./results.js";
|
|
3
|
+
import { ExpressionEvaluator } from "./evaluator.js";
|
|
4
|
+
import { CommandHandler, parseCommand } from "./commands.js";
|
|
5
|
+
|
|
6
|
+
export interface RunnerOptions {
|
|
7
|
+
startAt: string;
|
|
8
|
+
variables?: Record<string, unknown>;
|
|
9
|
+
functions?: Record<string, (...args: unknown[]) => unknown>;
|
|
10
|
+
handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
|
11
|
+
commandHandler?: CommandHandler;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const globalOnceSeen = new Set<string>();
|
|
15
|
+
const globalNodeGroupOnceSeen = new Set<string>(); // Track "once" nodes in groups: "title#index"
|
|
16
|
+
|
|
17
|
+
export class YarnRunner {
|
|
18
|
+
private readonly program: IRProgram;
|
|
19
|
+
private readonly variables: Record<string, unknown>;
|
|
20
|
+
private readonly functions: Record<string, (...args: unknown[]) => unknown>;
|
|
21
|
+
private readonly handleCommand?: (command: string, parsed?: ReturnType<typeof parseCommand>) => void;
|
|
22
|
+
private readonly commandHandler: CommandHandler;
|
|
23
|
+
private readonly evaluator: ExpressionEvaluator;
|
|
24
|
+
private readonly onceSeen = globalOnceSeen;
|
|
25
|
+
private readonly nodeGroupOnceSeen = globalNodeGroupOnceSeen;
|
|
26
|
+
private readonly visitCounts: Record<string, number> = {};
|
|
27
|
+
|
|
28
|
+
private nodeTitle: string;
|
|
29
|
+
private ip = 0; // instruction pointer within node
|
|
30
|
+
private currentNodeIndex: number = -1; // Index of selected node in group (-1 if single node)
|
|
31
|
+
private callStack: Array<
|
|
32
|
+
| ({ title: string; ip: number } & { kind: "detour" })
|
|
33
|
+
| ({ title: string; ip: number; block: IRInstruction[]; idx: number } & { kind: "block" })
|
|
34
|
+
> = [];
|
|
35
|
+
|
|
36
|
+
currentResult: RuntimeResult | null = null;
|
|
37
|
+
history: RuntimeResult[] = [];
|
|
38
|
+
|
|
39
|
+
constructor(program: IRProgram, opts: RunnerOptions) {
|
|
40
|
+
this.program = program;
|
|
41
|
+
this.variables = { ...(opts.variables ?? {}) };
|
|
42
|
+
this.functions = {
|
|
43
|
+
// Default conversion helpers
|
|
44
|
+
string: (v: unknown) => String(v ?? ""),
|
|
45
|
+
number: (v: unknown) => Number(v),
|
|
46
|
+
bool: (v: unknown) => Boolean(v),
|
|
47
|
+
visited: (nodeName: unknown) => {
|
|
48
|
+
const name = String(nodeName ?? "");
|
|
49
|
+
return (this.visitCounts[name] ?? 0) > 0;
|
|
50
|
+
},
|
|
51
|
+
visited_count: (nodeName: unknown) => {
|
|
52
|
+
const name = String(nodeName ?? "");
|
|
53
|
+
return this.visitCounts[name] ?? 0;
|
|
54
|
+
},
|
|
55
|
+
format_invariant: (n: unknown) => {
|
|
56
|
+
const num = Number(n);
|
|
57
|
+
if (!isFinite(num)) return "0";
|
|
58
|
+
return new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 20 }).format(num);
|
|
59
|
+
},
|
|
60
|
+
random: () => Math.random(),
|
|
61
|
+
random_range: (a: unknown, b: unknown) => {
|
|
62
|
+
const x = Number(a), y = Number(b);
|
|
63
|
+
const min = Math.min(x, y);
|
|
64
|
+
const max = Math.max(x, y);
|
|
65
|
+
return min + Math.random() * (max - min);
|
|
66
|
+
},
|
|
67
|
+
dice: (sides: unknown) => {
|
|
68
|
+
const s = Math.max(1, Math.floor(Number(sides)) || 1);
|
|
69
|
+
return Math.floor(Math.random() * s) + 1;
|
|
70
|
+
},
|
|
71
|
+
min: (a: unknown, b: unknown) => Math.min(Number(a), Number(b)),
|
|
72
|
+
max: (a: unknown, b: unknown) => Math.max(Number(a), Number(b)),
|
|
73
|
+
round: (n: unknown) => Math.round(Number(n)),
|
|
74
|
+
round_places: (n: unknown, places: unknown) => {
|
|
75
|
+
const p = Math.max(0, Math.floor(Number(places)) || 0);
|
|
76
|
+
const factor = Math.pow(10, p);
|
|
77
|
+
return Math.round(Number(n) * factor) / factor;
|
|
78
|
+
},
|
|
79
|
+
floor: (n: unknown) => Math.floor(Number(n)),
|
|
80
|
+
ceil: (n: unknown) => Math.ceil(Number(n)),
|
|
81
|
+
inc: (n: unknown) => {
|
|
82
|
+
const v = Number(n);
|
|
83
|
+
return Number.isInteger(v) ? v + 1 : Math.ceil(v);
|
|
84
|
+
},
|
|
85
|
+
dec: (n: unknown) => {
|
|
86
|
+
const v = Number(n);
|
|
87
|
+
return Number.isInteger(v) ? v - 1 : Math.floor(v);
|
|
88
|
+
},
|
|
89
|
+
decimal: (n: unknown) => {
|
|
90
|
+
const v = Number(n);
|
|
91
|
+
return Math.abs(v - Math.trunc(v));
|
|
92
|
+
},
|
|
93
|
+
int: (n: unknown) => Math.trunc(Number(n)),
|
|
94
|
+
...(opts.functions ?? {}),
|
|
95
|
+
} as Record<string, (...args: unknown[]) => unknown>;
|
|
96
|
+
this.handleCommand = opts.handleCommand;
|
|
97
|
+
this.evaluator = new ExpressionEvaluator(this.variables, this.functions, this.program.enums);
|
|
98
|
+
this.commandHandler = opts.commandHandler ?? new CommandHandler(this.variables);
|
|
99
|
+
this.nodeTitle = opts.startAt;
|
|
100
|
+
|
|
101
|
+
this.step();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get the current node title (may resolve to a node group).
|
|
106
|
+
*/
|
|
107
|
+
getCurrentNodeTitle(): string {
|
|
108
|
+
return this.nodeTitle;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a node title to an actual node (handling node groups).
|
|
113
|
+
*/
|
|
114
|
+
private resolveNode(title: string): IRNode {
|
|
115
|
+
const nodeOrGroup = this.program.nodes[title];
|
|
116
|
+
if (!nodeOrGroup) throw new Error(`Node ${title} not found`);
|
|
117
|
+
|
|
118
|
+
// If it's a single node, return it
|
|
119
|
+
if (!("nodes" in nodeOrGroup)) {
|
|
120
|
+
this.currentNodeIndex = -1;
|
|
121
|
+
return nodeOrGroup as IRNode;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// It's a node group - select the first matching node based on when conditions
|
|
125
|
+
const group = nodeOrGroup as IRNodeGroup;
|
|
126
|
+
for (let i = 0; i < group.nodes.length; i++) {
|
|
127
|
+
const candidate = group.nodes[i];
|
|
128
|
+
if (this.evaluateWhenConditions(candidate.when, title, i)) {
|
|
129
|
+
this.currentNodeIndex = i;
|
|
130
|
+
// If "once" condition, mark as seen immediately
|
|
131
|
+
if (candidate.when?.includes("once")) {
|
|
132
|
+
this.markNodeGroupOnceSeen(title, i);
|
|
133
|
+
}
|
|
134
|
+
return candidate;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// No matching node found - throw error or return first? Docs suggest error if no match
|
|
139
|
+
throw new Error(`No matching node found in group ${title}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Evaluate when conditions for a node in a group.
|
|
144
|
+
*/
|
|
145
|
+
private evaluateWhenConditions(conditions: string[] | undefined, nodeTitle: string, nodeIndex: number): boolean {
|
|
146
|
+
if (!conditions || conditions.length === 0) {
|
|
147
|
+
// No when condition - available by default (but should not happen in groups)
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// All conditions must be true (AND logic)
|
|
152
|
+
for (const condition of conditions) {
|
|
153
|
+
const trimmed = condition.trim();
|
|
154
|
+
|
|
155
|
+
if (trimmed === "once") {
|
|
156
|
+
// Check if this node has been visited once
|
|
157
|
+
const onceKey = `${nodeTitle}#${nodeIndex}`;
|
|
158
|
+
if (this.nodeGroupOnceSeen.has(onceKey)) {
|
|
159
|
+
return false; // Already seen once
|
|
160
|
+
}
|
|
161
|
+
// Will mark as seen when node is entered
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (trimmed === "always") {
|
|
166
|
+
// Always available
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Otherwise, treat as expression (e.g., "$has_sword")
|
|
171
|
+
if (!this.evaluator.evaluate(trimmed)) {
|
|
172
|
+
return false; // Condition failed
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return true; // All conditions passed
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Mark a node group node as seen (for "once" condition).
|
|
181
|
+
*/
|
|
182
|
+
private markNodeGroupOnceSeen(nodeTitle: string, nodeIndex: number): void {
|
|
183
|
+
const onceKey = `${nodeTitle}#${nodeIndex}`;
|
|
184
|
+
this.nodeGroupOnceSeen.add(onceKey);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
advance(optionIndex?: number) {
|
|
188
|
+
// If awaiting option selection, consume chosen option by pushing its block
|
|
189
|
+
if (this.currentResult?.type === "options") {
|
|
190
|
+
if (optionIndex == null) throw new Error("Option index required");
|
|
191
|
+
// Resolve to actual node (handles groups)
|
|
192
|
+
const node = this.resolveNode(this.nodeTitle);
|
|
193
|
+
// We encoded options at ip-1; locate it
|
|
194
|
+
const ins = node.instructions[this.ip - 1];
|
|
195
|
+
if (ins?.op !== "options") throw new Error("Invalid options state");
|
|
196
|
+
const chosen = ins.options[optionIndex];
|
|
197
|
+
if (!chosen) throw new Error("Invalid option index");
|
|
198
|
+
// Push a block frame that we will resume across advances
|
|
199
|
+
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: chosen.block, idx: 0 });
|
|
200
|
+
if (this.resumeBlock()) return;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// If we have a pending block, resume it first
|
|
204
|
+
if (this.resumeBlock()) return;
|
|
205
|
+
this.step();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private interpolate(text: string): string {
|
|
209
|
+
return text.replace(/\{([^}]+)\}/g, (_m, expr) => {
|
|
210
|
+
try {
|
|
211
|
+
const val = this.evaluator.evaluateExpression(String(expr));
|
|
212
|
+
return String(val ?? "");
|
|
213
|
+
} catch {
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private resumeBlock(): boolean {
|
|
220
|
+
const top = this.callStack[this.callStack.length - 1];
|
|
221
|
+
if (!top || top.kind !== "block") return false;
|
|
222
|
+
// Execute from stored idx until we emit one result or finish block
|
|
223
|
+
while (true) {
|
|
224
|
+
const ins = top.block[top.idx++];
|
|
225
|
+
if (!ins) {
|
|
226
|
+
// finished block; pop and continue main step
|
|
227
|
+
this.callStack.pop();
|
|
228
|
+
this.step();
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
switch (ins.op) {
|
|
232
|
+
case "line":
|
|
233
|
+
this.emit({ type: "text", text: this.interpolate(ins.text), speaker: ins.speaker, tags: ins.tags, isDialogueEnd: false });
|
|
234
|
+
return true;
|
|
235
|
+
case "command": {
|
|
236
|
+
try {
|
|
237
|
+
const parsed = parseCommand(ins.content);
|
|
238
|
+
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
|
|
239
|
+
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
|
240
|
+
} catch {
|
|
241
|
+
if (this.handleCommand) this.handleCommand(ins.content);
|
|
242
|
+
}
|
|
243
|
+
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
case "options": {
|
|
247
|
+
this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text, tags: o.tags })), isDialogueEnd: false });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
case "if": {
|
|
251
|
+
const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
|
|
252
|
+
if (branch) {
|
|
253
|
+
// Push nested block at current top position (resume after)
|
|
254
|
+
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
|
|
255
|
+
return this.resumeBlock();
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case "once": {
|
|
260
|
+
if (!this.onceSeen.has(ins.id)) {
|
|
261
|
+
this.onceSeen.add(ins.id);
|
|
262
|
+
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
|
|
263
|
+
return this.resumeBlock();
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case "jump": {
|
|
268
|
+
this.nodeTitle = ins.target;
|
|
269
|
+
this.ip = 0;
|
|
270
|
+
this.step();
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
case "detour": {
|
|
274
|
+
this.callStack.push({ kind: "detour", title: top.title, ip: top.ip });
|
|
275
|
+
this.nodeTitle = ins.target;
|
|
276
|
+
this.ip = 0;
|
|
277
|
+
this.step();
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private step() {
|
|
285
|
+
while (true) {
|
|
286
|
+
const resolved = this.resolveNode(this.nodeTitle);
|
|
287
|
+
const currentNode: IRNode = { title: this.nodeTitle, instructions: resolved.instructions };
|
|
288
|
+
const ins = currentNode.instructions[this.ip];
|
|
289
|
+
if (!ins) {
|
|
290
|
+
// Node ended
|
|
291
|
+
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
|
|
292
|
+
this.emit({ type: "text", text: "", nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: true });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
this.ip++;
|
|
296
|
+
switch (ins.op) {
|
|
297
|
+
case "line":
|
|
298
|
+
this.emit({ type: "text", text: this.interpolate(ins.text), speaker: ins.speaker, tags: ins.tags, nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
|
|
299
|
+
return;
|
|
300
|
+
case "command": {
|
|
301
|
+
try {
|
|
302
|
+
const parsed = parseCommand(ins.content);
|
|
303
|
+
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
|
|
304
|
+
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
|
305
|
+
} catch {
|
|
306
|
+
if (this.handleCommand) this.handleCommand(ins.content);
|
|
307
|
+
}
|
|
308
|
+
this.emit({ type: "command", command: ins.content, isDialogueEnd: this.lookaheadIsEnd() });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
case "jump": {
|
|
312
|
+
// Exiting current node due to jump
|
|
313
|
+
this.visitCounts[this.nodeTitle] = (this.visitCounts[this.nodeTitle] ?? 0) + 1;
|
|
314
|
+
this.nodeTitle = ins.target;
|
|
315
|
+
this.ip = 0;
|
|
316
|
+
this.currentNodeIndex = -1; // Reset node index for new resolution
|
|
317
|
+
// resolveNode will handle node groups
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
case "detour": {
|
|
321
|
+
// Save return position, jump to target node, return when it ends
|
|
322
|
+
this.callStack.push({ kind: "detour", title: this.nodeTitle, ip: this.ip });
|
|
323
|
+
this.nodeTitle = ins.target;
|
|
324
|
+
this.ip = 0;
|
|
325
|
+
this.currentNodeIndex = -1; // Reset node index for new resolution
|
|
326
|
+
// resolveNode will handle node groups
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
case "options": {
|
|
330
|
+
this.emit({ type: "options", options: ins.options.map((o: { text: string; tags?: string[]; css?: string }) => ({ text: o.text, tags: o.tags, css: o.css })), nodeCss: resolved.css, scene: resolved.scene, isDialogueEnd: this.lookaheadIsEnd() });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
case "if": {
|
|
334
|
+
const branch = ins.branches.find((b: { condition: string | null; block: IRInstruction[] }) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
|
|
335
|
+
if (branch) {
|
|
336
|
+
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
|
|
337
|
+
if (this.resumeBlock()) return;
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case "once": {
|
|
342
|
+
if (!this.onceSeen.has(ins.id)) {
|
|
343
|
+
this.onceSeen.add(ins.id);
|
|
344
|
+
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
|
|
345
|
+
if (this.resumeBlock()) return;
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private executeBlock(block: { title: string; instructions: IRInstruction[] }) {
|
|
354
|
+
// Execute instructions of block, then resume
|
|
355
|
+
const saved = { title: this.nodeTitle, ip: this.ip } as const;
|
|
356
|
+
this.nodeTitle = block.title;
|
|
357
|
+
const tempIpStart = 0;
|
|
358
|
+
const tempNode = { title: block.title, instructions: block.instructions } as const;
|
|
359
|
+
// Use a temporary node context
|
|
360
|
+
const restore = () => {
|
|
361
|
+
this.nodeTitle = saved.title;
|
|
362
|
+
this.ip = saved.ip;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Step through block, emitting first result
|
|
366
|
+
let idx = tempIpStart;
|
|
367
|
+
while (true) {
|
|
368
|
+
const ins = tempNode.instructions[idx++];
|
|
369
|
+
if (!ins) break;
|
|
370
|
+
switch (ins.op) {
|
|
371
|
+
case "line":
|
|
372
|
+
this.emit({ type: "text", text: ins.text, speaker: ins.speaker, isDialogueEnd: false });
|
|
373
|
+
restore();
|
|
374
|
+
return;
|
|
375
|
+
case "command":
|
|
376
|
+
try {
|
|
377
|
+
const parsed = parseCommand(ins.content);
|
|
378
|
+
this.commandHandler.execute(parsed, this.evaluator).catch(() => {});
|
|
379
|
+
if (this.handleCommand) this.handleCommand(ins.content, parsed);
|
|
380
|
+
} catch {
|
|
381
|
+
if (this.handleCommand) this.handleCommand(ins.content);
|
|
382
|
+
}
|
|
383
|
+
this.emit({ type: "command", command: ins.content, isDialogueEnd: false });
|
|
384
|
+
restore();
|
|
385
|
+
return;
|
|
386
|
+
case "options":
|
|
387
|
+
this.emit({ type: "options", options: ins.options.map((o) => ({ text: o.text })), isDialogueEnd: false });
|
|
388
|
+
// Maintain context that options belong to main node at ip-1
|
|
389
|
+
restore();
|
|
390
|
+
return;
|
|
391
|
+
case "if": {
|
|
392
|
+
const branch = ins.branches.find((b) => (b.condition ? this.evaluator.evaluate(b.condition) : true));
|
|
393
|
+
if (branch) {
|
|
394
|
+
// enqueue nested block and resume from main context
|
|
395
|
+
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: branch.block, idx: 0 });
|
|
396
|
+
restore();
|
|
397
|
+
if (this.resumeBlock()) return;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
case "once": {
|
|
403
|
+
if (!this.onceSeen.has(ins.id)) {
|
|
404
|
+
this.onceSeen.add(ins.id);
|
|
405
|
+
this.callStack.push({ kind: "block", title: this.nodeTitle, ip: this.ip, block: ins.block, idx: 0 });
|
|
406
|
+
restore();
|
|
407
|
+
if (this.resumeBlock()) return;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case "jump": {
|
|
413
|
+
this.nodeTitle = ins.target;
|
|
414
|
+
this.ip = 0;
|
|
415
|
+
this.step();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
case "detour": {
|
|
419
|
+
this.callStack.push({ kind: "detour", title: saved.title, ip: saved.ip });
|
|
420
|
+
this.nodeTitle = ins.target;
|
|
421
|
+
this.ip = 0;
|
|
422
|
+
this.step();
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Block produced no output; resume
|
|
428
|
+
restore();
|
|
429
|
+
this.step();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private lookaheadIsEnd(): boolean {
|
|
433
|
+
// Check if current node has more emit-worthy instructions
|
|
434
|
+
const node = this.resolveNode(this.nodeTitle);
|
|
435
|
+
for (let k = this.ip; k < node.instructions.length; k++) {
|
|
436
|
+
const op = node.instructions[k]?.op;
|
|
437
|
+
if (!op) break;
|
|
438
|
+
if (op === "line" || op === "options" || op === "command" || op === "if" || op === "once") return false;
|
|
439
|
+
if (op === "jump" || op === "detour") return false;
|
|
440
|
+
}
|
|
441
|
+
// Node is ending - mark as end (will trigger detour return if callStack exists)
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private emit(res: RuntimeResult) {
|
|
446
|
+
this.currentResult = res;
|
|
447
|
+
this.history.push(res);
|
|
448
|
+
// If we ended a detour node, return to caller after emitting last result
|
|
449
|
+
// Position is restored here, but we wait for next advance() to continue
|
|
450
|
+
if (res.isDialogueEnd && this.callStack.length > 0) {
|
|
451
|
+
const frame = this.callStack.pop()!;
|
|
452
|
+
this.nodeTitle = frame.title;
|
|
453
|
+
this.ip = frame.ip;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get the current variable store (read-only view).
|
|
459
|
+
*/
|
|
460
|
+
getVariables(): Readonly<Record<string, unknown>> {
|
|
461
|
+
return { ...this.variables };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Get variable value.
|
|
466
|
+
*/
|
|
467
|
+
getVariable(name: string): unknown {
|
|
468
|
+
return this.variables[name];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Set variable value.
|
|
473
|
+
*/
|
|
474
|
+
setVariable(name: string, value: unknown): void {
|
|
475
|
+
this.variables[name] = value;
|
|
476
|
+
this.evaluator.setVariable(name, value);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene configuration parser using js-yaml
|
|
3
|
+
* Supports YAML string or plain object
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import yaml from "js-yaml";
|
|
7
|
+
import type { SceneCollection, SceneConfig, ActorConfig } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse scene configuration from YAML string or object
|
|
11
|
+
*/
|
|
12
|
+
export function parseScenes(input: string | Record<string, unknown>): SceneCollection {
|
|
13
|
+
// If already an object, use it directly
|
|
14
|
+
if (typeof input === "object" && input !== null) {
|
|
15
|
+
return parseScenesFromObject(input);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Parse YAML string
|
|
19
|
+
try {
|
|
20
|
+
const parsed = yaml.load(input) as Record<string, unknown>;
|
|
21
|
+
return parseScenesFromObject(parsed || {});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error("Failed to parse YAML scene config:", error);
|
|
24
|
+
throw new Error(`Invalid YAML scene configuration: ${error instanceof Error ? error.message : String(error)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseScenesFromObject(obj: Record<string, unknown>): SceneCollection {
|
|
29
|
+
const scenes: Record<string, SceneConfig> = {};
|
|
30
|
+
|
|
31
|
+
// Extract global actors if defined separately
|
|
32
|
+
const globalActors: Record<string, ActorConfig> = {};
|
|
33
|
+
if (typeof obj.actors === "object" && obj.actors !== null) {
|
|
34
|
+
for (const [actorName, actorData] of Object.entries(obj.actors)) {
|
|
35
|
+
if (typeof actorData === "object" && actorData !== null) {
|
|
36
|
+
// Nested format: actorName: { image: "..." }
|
|
37
|
+
globalActors[actorName] = {
|
|
38
|
+
image: typeof actorData.image === "string" ? actorData.image : undefined,
|
|
39
|
+
};
|
|
40
|
+
} else if (typeof actorData === "string") {
|
|
41
|
+
// Shorthand: actorName: "/path/to/image.png"
|
|
42
|
+
globalActors[actorName] = { image: actorData };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse scenes
|
|
48
|
+
if (typeof obj.scenes === "object" && obj.scenes !== null) {
|
|
49
|
+
const scenesObj = obj.scenes as Record<string, unknown>;
|
|
50
|
+
for (const [sceneName, sceneData] of Object.entries(scenesObj)) {
|
|
51
|
+
if (typeof sceneData === "object" && sceneData !== null) {
|
|
52
|
+
const data = sceneData as Record<string, unknown>;
|
|
53
|
+
const sceneActors: Record<string, ActorConfig> = { ...globalActors }; // Start with global actors
|
|
54
|
+
|
|
55
|
+
// Override with scene-specific actors if defined
|
|
56
|
+
if (typeof data.actors === "object" && data.actors !== null) {
|
|
57
|
+
for (const [actorName, actorData] of Object.entries(data.actors)) {
|
|
58
|
+
if (typeof actorData === "object" && actorData !== null) {
|
|
59
|
+
sceneActors[actorName] = {
|
|
60
|
+
image: typeof actorData.image === "string" ? actorData.image : undefined,
|
|
61
|
+
};
|
|
62
|
+
} else if (typeof actorData === "string") {
|
|
63
|
+
sceneActors[actorName] = { image: actorData };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
scenes[sceneName] = {
|
|
69
|
+
background: typeof data.background === "string" ? data.background : "",
|
|
70
|
+
actors: sceneActors,
|
|
71
|
+
};
|
|
72
|
+
} else if (typeof sceneData === "string") {
|
|
73
|
+
// Shorthand: scene1: "/path/to/background.png" (uses global actors)
|
|
74
|
+
scenes[sceneName] = {
|
|
75
|
+
background: sceneData,
|
|
76
|
+
actors: { ...globalActors },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { scenes };
|
|
83
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene configuration types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface ActorConfig {
|
|
6
|
+
image?: string; // Path to actor image
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SceneConfig {
|
|
10
|
+
background: string; // Path to background image
|
|
11
|
+
actors: Record<string, ActorConfig>; // Actor name -> config
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SceneCollection {
|
|
15
|
+
scenes: Record<string, SceneConfig>; // Scene name -> config
|
|
16
|
+
}
|
|
17
|
+
|