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,435 @@
|
|
|
1
|
+
import { lex, Token } from "./lexer.js";
|
|
2
|
+
import type {
|
|
3
|
+
YarnDocument,
|
|
4
|
+
YarnNode,
|
|
5
|
+
Statement,
|
|
6
|
+
Line,
|
|
7
|
+
Command,
|
|
8
|
+
OptionGroup,
|
|
9
|
+
Option,
|
|
10
|
+
IfBlock,
|
|
11
|
+
OnceBlock,
|
|
12
|
+
Jump,
|
|
13
|
+
Detour,
|
|
14
|
+
EnumBlock,
|
|
15
|
+
} from "../model/ast";
|
|
16
|
+
|
|
17
|
+
export class ParseError extends Error {}
|
|
18
|
+
|
|
19
|
+
export function parseYarn(text: string): YarnDocument {
|
|
20
|
+
const tokens = lex(text);
|
|
21
|
+
const p = new Parser(tokens);
|
|
22
|
+
return p.parseDocument();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class Parser {
|
|
26
|
+
private i = 0;
|
|
27
|
+
constructor(private readonly tokens: Token[]) {}
|
|
28
|
+
|
|
29
|
+
private peek(offset = 0) {
|
|
30
|
+
return this.tokens[this.i + offset];
|
|
31
|
+
}
|
|
32
|
+
private at(type: Token["type"]) {
|
|
33
|
+
return this.peek()?.type === type;
|
|
34
|
+
}
|
|
35
|
+
private take(type: Token["type"], err?: string): Token {
|
|
36
|
+
const t = this.peek();
|
|
37
|
+
if (!t || t.type !== type) throw new ParseError(err ?? `Expected ${type}, got ${t?.type}`);
|
|
38
|
+
this.i++;
|
|
39
|
+
return t;
|
|
40
|
+
}
|
|
41
|
+
private takeIf(type: Token["type"]) {
|
|
42
|
+
if (this.at(type)) return this.take(type);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
parseDocument(): YarnDocument {
|
|
47
|
+
const enums: EnumBlock[] = [];
|
|
48
|
+
const nodes: YarnNode[] = [];
|
|
49
|
+
while (!this.at("EOF")) {
|
|
50
|
+
// Skip empties
|
|
51
|
+
while (this.at("EMPTY")) this.i++;
|
|
52
|
+
if (this.at("EOF")) break;
|
|
53
|
+
|
|
54
|
+
// Check if this is an enum definition (top-level)
|
|
55
|
+
if (this.at("COMMAND")) {
|
|
56
|
+
const cmd = this.peek().text.trim();
|
|
57
|
+
if (cmd.startsWith("enum ")) {
|
|
58
|
+
const enumCmd = this.take("COMMAND").text; // consume the enum command
|
|
59
|
+
const enumName = enumCmd.slice(5).trim();
|
|
60
|
+
const enumDef = this.parseEnumBlock(enumName);
|
|
61
|
+
enums.push(enumDef);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
nodes.push(this.parseNode());
|
|
67
|
+
}
|
|
68
|
+
return { type: "Document", enums, nodes };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private parseNode(): YarnNode {
|
|
72
|
+
const headers: Record<string, string> = {};
|
|
73
|
+
let title: string | null = null;
|
|
74
|
+
let nodeTags: string[] | undefined;
|
|
75
|
+
let whenConditions: string[] = [];
|
|
76
|
+
let nodeCss: string | undefined;
|
|
77
|
+
|
|
78
|
+
// headers
|
|
79
|
+
while (!this.at("NODE_START")) {
|
|
80
|
+
const keyTok = this.take("HEADER_KEY", "Expected node header before '---'");
|
|
81
|
+
const valTok = this.take("HEADER_VALUE", "Expected header value");
|
|
82
|
+
if (keyTok.text === "title") title = valTok.text.trim();
|
|
83
|
+
if (keyTok.text === "tags") {
|
|
84
|
+
const raw = valTok.text.trim();
|
|
85
|
+
nodeTags = raw.split(/\s+/).filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
if (keyTok.text === "when") {
|
|
88
|
+
// Each when: header adds one condition (can have multiple when: headers)
|
|
89
|
+
const raw = valTok.text.trim();
|
|
90
|
+
whenConditions.push(raw);
|
|
91
|
+
}
|
|
92
|
+
// Capture &css{ ... } styles in any header value
|
|
93
|
+
const rawVal = valTok.text.trim();
|
|
94
|
+
if (rawVal.startsWith("&css{")) {
|
|
95
|
+
// Collect until closing '}' possibly spanning multiple lines before '---'
|
|
96
|
+
let cssContent = rawVal.replace(/^&css\{/, "");
|
|
97
|
+
let closed = cssContent.includes("}");
|
|
98
|
+
if (closed) {
|
|
99
|
+
cssContent = cssContent.split("}")[0];
|
|
100
|
+
} else {
|
|
101
|
+
// Consume subsequent TEXT or HEADER_VALUE tokens until we find a '}'
|
|
102
|
+
while (!this.at("NODE_START") && !this.at("EOF")) {
|
|
103
|
+
const next = this.peek();
|
|
104
|
+
if (next.type === "TEXT" || next.type === "HEADER_VALUE") {
|
|
105
|
+
const t = this.take(next.type).text;
|
|
106
|
+
if (t.includes("}")) {
|
|
107
|
+
cssContent += (cssContent ? "\n" : "") + t.split("}")[0];
|
|
108
|
+
closed = true;
|
|
109
|
+
break;
|
|
110
|
+
} else {
|
|
111
|
+
cssContent += (cssContent ? "\n" : "") + t;
|
|
112
|
+
}
|
|
113
|
+
} else if (next.type === "EMPTY") {
|
|
114
|
+
this.i++;
|
|
115
|
+
} else {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
nodeCss = (cssContent || "").trim();
|
|
121
|
+
}
|
|
122
|
+
headers[keyTok.text] = valTok.text;
|
|
123
|
+
// allow empty lines
|
|
124
|
+
while (this.at("EMPTY")) this.i++;
|
|
125
|
+
}
|
|
126
|
+
if (!title) throw new ParseError("Every node must have a title header");
|
|
127
|
+
this.take("NODE_START");
|
|
128
|
+
// allow optional empties after ---
|
|
129
|
+
while (this.at("EMPTY")) this.i++;
|
|
130
|
+
|
|
131
|
+
const body: Statement[] = this.parseStatementsUntil("NODE_END");
|
|
132
|
+
this.take("NODE_END", "Expected node end '==='");
|
|
133
|
+
return {
|
|
134
|
+
type: "Node",
|
|
135
|
+
title,
|
|
136
|
+
headers,
|
|
137
|
+
nodeTags,
|
|
138
|
+
when: whenConditions.length > 0 ? whenConditions : undefined,
|
|
139
|
+
css: nodeCss,
|
|
140
|
+
body
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private parseStatementsUntil(endType: Token["type"]): Statement[] {
|
|
145
|
+
const out: Statement[] = [];
|
|
146
|
+
while (!this.at(endType) && !this.at("EOF")) {
|
|
147
|
+
// skip extra empties
|
|
148
|
+
while (this.at("EMPTY")) this.i++;
|
|
149
|
+
if (this.at(endType) || this.at("EOF")) break;
|
|
150
|
+
|
|
151
|
+
if (this.at("OPTION")) {
|
|
152
|
+
out.push(this.parseOptionGroup());
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const stmt = this.parseStatement();
|
|
157
|
+
out.push(stmt);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private parseStatement(): Statement {
|
|
163
|
+
const t = this.peek();
|
|
164
|
+
if (!t) throw new ParseError("Unexpected EOF");
|
|
165
|
+
|
|
166
|
+
if (t.type === "COMMAND") {
|
|
167
|
+
const cmd = this.take("COMMAND").text;
|
|
168
|
+
if (cmd.startsWith("jump ")) return { type: "Jump", target: cmd.slice(5).trim() } as Jump;
|
|
169
|
+
if (cmd.startsWith("detour ")) return { type: "Detour", target: cmd.slice(7).trim() } as Detour;
|
|
170
|
+
if (cmd.startsWith("if ")) return this.parseIfCommandBlock(cmd);
|
|
171
|
+
if (cmd === "once") return this.parseOnceBlock();
|
|
172
|
+
if (cmd.startsWith("enum ")) {
|
|
173
|
+
const enumName = cmd.slice(5).trim();
|
|
174
|
+
return this.parseEnumBlock(enumName);
|
|
175
|
+
}
|
|
176
|
+
return { type: "Command", content: cmd } as Command;
|
|
177
|
+
}
|
|
178
|
+
if (t.type === "TEXT") {
|
|
179
|
+
const raw = this.take("TEXT").text;
|
|
180
|
+
const { cleanText: text, tags } = this.extractTags(raw);
|
|
181
|
+
const speakerMatch = text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
|
|
182
|
+
if (speakerMatch) {
|
|
183
|
+
return { type: "Line", speaker: speakerMatch[1].trim(), text: speakerMatch[2], tags } as Line;
|
|
184
|
+
}
|
|
185
|
+
// If/Else blocks use inline markup {if ...}
|
|
186
|
+
const trimmed = text.trim();
|
|
187
|
+
if (trimmed.startsWith("{if ") || trimmed === "{else}" || trimmed.startsWith("{else if ") || trimmed === "{endif}") {
|
|
188
|
+
return this.parseIfFromText(text);
|
|
189
|
+
}
|
|
190
|
+
return { type: "Line", text, tags } as Line;
|
|
191
|
+
}
|
|
192
|
+
throw new ParseError(`Unexpected token ${t.type}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private parseOptionGroup(): OptionGroup {
|
|
196
|
+
const options: Option[] = [];
|
|
197
|
+
// One or more OPTION lines, with bodies under INDENT
|
|
198
|
+
while (this.at("OPTION")) {
|
|
199
|
+
const raw = this.take("OPTION").text;
|
|
200
|
+
const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
|
|
201
|
+
const { text, css } = this.extractCss(textWithAttrs);
|
|
202
|
+
let body: Statement[] = [];
|
|
203
|
+
if (this.at("INDENT")) {
|
|
204
|
+
this.take("INDENT");
|
|
205
|
+
body = this.parseStatementsUntil("DEDENT");
|
|
206
|
+
this.take("DEDENT");
|
|
207
|
+
while (this.at("EMPTY")) this.i++;
|
|
208
|
+
}
|
|
209
|
+
options.push({ type: "Option", text, body, tags, css });
|
|
210
|
+
// Consecutive options belong to the same group; break on non-OPTION
|
|
211
|
+
while (this.at("EMPTY")) this.i++;
|
|
212
|
+
}
|
|
213
|
+
return { type: "OptionGroup", options };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private extractTags(input: string): { cleanText: string; tags?: string[] } {
|
|
217
|
+
const tags: string[] = [];
|
|
218
|
+
// Match tags that are space-separated and not part of hex colors or CSS
|
|
219
|
+
// Tags are like "#tag" preceded by whitespace and not followed by hex digits
|
|
220
|
+
const re = /\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g;
|
|
221
|
+
let text = input;
|
|
222
|
+
let m: RegExpExecArray | null;
|
|
223
|
+
while ((m = re.exec(input))) {
|
|
224
|
+
tags.push(m[1]);
|
|
225
|
+
}
|
|
226
|
+
if (tags.length > 0) {
|
|
227
|
+
// Only remove tags that match the pattern (not hex colors in CSS)
|
|
228
|
+
text = input.replace(/\s#([a-zA-Z_][a-zA-Z0-9_]*)(?!\w)/g, "").trimEnd();
|
|
229
|
+
return { cleanText: text, tags };
|
|
230
|
+
}
|
|
231
|
+
return { cleanText: input };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private extractCss(input: string): { text: string; css?: string } {
|
|
235
|
+
const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
|
|
236
|
+
if (cssMatch) {
|
|
237
|
+
const css = cssMatch[1].trim();
|
|
238
|
+
const text = input.replace(cssMatch[0], "").trimEnd();
|
|
239
|
+
return { text, css };
|
|
240
|
+
}
|
|
241
|
+
return { text: input };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private parseStatementsUntilStop(shouldStop: () => boolean): Statement[] {
|
|
245
|
+
const out: Statement[] = [];
|
|
246
|
+
while (!this.at("EOF")) {
|
|
247
|
+
// Check stop condition at root level only
|
|
248
|
+
if (shouldStop()) break;
|
|
249
|
+
while (this.at("EMPTY")) this.i++;
|
|
250
|
+
if (this.at("EOF") || shouldStop()) break;
|
|
251
|
+
// Handle indentation - if we see INDENT, parse the indented block
|
|
252
|
+
if (this.at("INDENT")) {
|
|
253
|
+
this.take("INDENT");
|
|
254
|
+
// Parse statements at this indent level until DEDENT (don't check stop condition inside)
|
|
255
|
+
while (!this.at("DEDENT") && !this.at("EOF")) {
|
|
256
|
+
while (this.at("EMPTY")) this.i++;
|
|
257
|
+
if (this.at("DEDENT") || this.at("EOF")) break;
|
|
258
|
+
if (this.at("OPTION")) {
|
|
259
|
+
out.push(this.parseOptionGroup());
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
out.push(this.parseStatement());
|
|
263
|
+
}
|
|
264
|
+
if (this.at("DEDENT")) {
|
|
265
|
+
this.take("DEDENT");
|
|
266
|
+
while (this.at("EMPTY")) this.i++;
|
|
267
|
+
}
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (this.at("OPTION")) {
|
|
271
|
+
out.push(this.parseOptionGroup());
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
out.push(this.parseStatement());
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private parseOnceBlock(): OnceBlock {
|
|
280
|
+
// Already consumed <<once>>; expect body under INDENT then <<endonce>> as COMMAND
|
|
281
|
+
let body: Statement[] = [];
|
|
282
|
+
if (this.at("INDENT")) {
|
|
283
|
+
this.take("INDENT");
|
|
284
|
+
body = this.parseStatementsUntil("DEDENT");
|
|
285
|
+
this.take("DEDENT");
|
|
286
|
+
} else {
|
|
287
|
+
// Alternatively, body until explicit <<endonce>> command on single line
|
|
288
|
+
body = [];
|
|
289
|
+
}
|
|
290
|
+
// consume closing command if present on own line
|
|
291
|
+
if (this.at("COMMAND") && this.peek().text === "endonce") {
|
|
292
|
+
this.take("COMMAND");
|
|
293
|
+
}
|
|
294
|
+
return { type: "Once", body };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private parseIfFromText(firstLine: string): IfBlock {
|
|
298
|
+
const branches: IfBlock["branches"] = [];
|
|
299
|
+
// expecting state not required in current implementation
|
|
300
|
+
|
|
301
|
+
let cursor = firstLine.trim();
|
|
302
|
+
function parseCond(text: string) {
|
|
303
|
+
const mIf = text.match(/^\{if\s+(.+?)\}$/);
|
|
304
|
+
if (mIf) return mIf[1];
|
|
305
|
+
const mElIf = text.match(/^\{else\s+if\s+(.+?)\}$/);
|
|
306
|
+
if (mElIf) return mElIf[1];
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
while (true) {
|
|
311
|
+
const cond = parseCond(cursor);
|
|
312
|
+
if (cursor === "{else}") {
|
|
313
|
+
branches.push({ condition: null, body: this.parseIfBlockBody() });
|
|
314
|
+
// next must be {endif}
|
|
315
|
+
const endLine = this.take("TEXT", "Expected {endif}").text.trim();
|
|
316
|
+
if (endLine !== "{endif}") throw new ParseError("Expected {endif}");
|
|
317
|
+
break;
|
|
318
|
+
} else if (cond) {
|
|
319
|
+
branches.push({ condition: cond, body: this.parseIfBlockBody() });
|
|
320
|
+
// next control line
|
|
321
|
+
const next = this.take("TEXT", "Expected {else}, {else if}, or {endif}").text.trim();
|
|
322
|
+
if (next === "{endif}") break;
|
|
323
|
+
cursor = next;
|
|
324
|
+
continue;
|
|
325
|
+
} else if (cursor === "{endif}") {
|
|
326
|
+
break;
|
|
327
|
+
} else {
|
|
328
|
+
throw new ParseError("Invalid if/else control line");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { type: "If", branches };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private parseEnumBlock(enumName: string): EnumBlock {
|
|
335
|
+
const cases: string[] = [];
|
|
336
|
+
|
|
337
|
+
// Parse cases until <<endenum>>
|
|
338
|
+
while (!this.at("EOF")) {
|
|
339
|
+
while (this.at("EMPTY")) this.i++;
|
|
340
|
+
if (this.at("COMMAND")) {
|
|
341
|
+
const cmd = this.peek().text.trim();
|
|
342
|
+
if (cmd === "endenum") {
|
|
343
|
+
this.take("COMMAND");
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
if (cmd.startsWith("case ")) {
|
|
347
|
+
this.take("COMMAND");
|
|
348
|
+
const caseName = cmd.slice(5).trim();
|
|
349
|
+
cases.push(caseName);
|
|
350
|
+
} else {
|
|
351
|
+
// Unknown command, might be inside enum block - skip or break?
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
// Skip non-command lines
|
|
356
|
+
if (this.at("TEXT")) this.take("TEXT");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { type: "Enum", name: enumName, cases };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private parseIfCommandBlock(firstCmd: string): IfBlock {
|
|
364
|
+
const branches: IfBlock["branches"] = [];
|
|
365
|
+
const firstCond = firstCmd.slice(3).trim();
|
|
366
|
+
// Body until next elseif/else/endif command (check at root level, not inside indented blocks)
|
|
367
|
+
const firstBody = this.parseStatementsUntilStop(() => {
|
|
368
|
+
// Only stop at root level commands, not inside indented blocks
|
|
369
|
+
return this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text);
|
|
370
|
+
});
|
|
371
|
+
branches.push({ condition: firstCond, body: firstBody });
|
|
372
|
+
|
|
373
|
+
while (!this.at("EOF")) {
|
|
374
|
+
if (!this.at("COMMAND")) break;
|
|
375
|
+
const t = this.peek();
|
|
376
|
+
const txt = t.text.trim();
|
|
377
|
+
if (txt.startsWith("elseif ")) {
|
|
378
|
+
this.take("COMMAND");
|
|
379
|
+
const cond = txt.slice(7).trim();
|
|
380
|
+
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(elseif\s|else$|endif$)/.test(this.peek().text));
|
|
381
|
+
branches.push({ condition: cond, body });
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (txt === "else") {
|
|
385
|
+
this.take("COMMAND");
|
|
386
|
+
const body = this.parseStatementsUntilStop(() => this.at("COMMAND") && /^(endif$)/.test(this.peek().text));
|
|
387
|
+
branches.push({ condition: null, body });
|
|
388
|
+
// require endif after else body
|
|
389
|
+
if (this.at("COMMAND") && this.peek().text.trim() === "endif") {
|
|
390
|
+
this.take("COMMAND");
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
if (txt === "endif") {
|
|
395
|
+
this.take("COMMAND");
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return { type: "If", branches };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private parseIfBlockBody(): Statement[] {
|
|
405
|
+
// Body is indented lines until next control line or DEDENT boundary; to keep this simple
|
|
406
|
+
// we consume subsequent lines until encountering a control TEXT or EOF/OPTION/NODE_END.
|
|
407
|
+
const body: Statement[] = [];
|
|
408
|
+
while (!this.at("EOF") && !this.at("NODE_END")) {
|
|
409
|
+
// Stop when next TEXT is a control or when OPTION starts (new group)
|
|
410
|
+
if (this.at("TEXT")) {
|
|
411
|
+
const look = this.peek().text.trim();
|
|
412
|
+
if (look === "{else}" || look === "{endif}" || look.startsWith("{else if ") || look.startsWith("{if ")) break;
|
|
413
|
+
}
|
|
414
|
+
if (this.at("OPTION")) break;
|
|
415
|
+
// Support indented bodies inside if-branches
|
|
416
|
+
if (this.at("INDENT")) {
|
|
417
|
+
this.take("INDENT");
|
|
418
|
+
const nested = this.parseStatementsUntil("DEDENT");
|
|
419
|
+
this.take("DEDENT");
|
|
420
|
+
body.push(...nested);
|
|
421
|
+
// continue scanning after dedent
|
|
422
|
+
while (this.at("EMPTY")) this.i++;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (this.at("EMPTY")) {
|
|
426
|
+
this.i++;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
body.push(this.parseStatement());
|
|
430
|
+
}
|
|
431
|
+
return body;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import React, { useState, useMemo } from "react";
|
|
2
|
+
import { parseYarn } from "../parse/parser.js";
|
|
3
|
+
import { compile } from "../compile/compiler.js";
|
|
4
|
+
import { useYarnRunner } from "./useYarnRunner.js";
|
|
5
|
+
import { DialogueView } from "./DialogueView.js";
|
|
6
|
+
import { parseScenes } from "../scene/parser.js";
|
|
7
|
+
import type { SceneCollection } from "../scene/types.js";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_YARN = `title: Start
|
|
10
|
+
scene: scene1
|
|
11
|
+
---
|
|
12
|
+
Narrator: Welcome to yarn-spinner-ts!
|
|
13
|
+
Narrator: This is a dialogue system powered by Yarn Spinner.
|
|
14
|
+
Narrator: Click anywhere to continue, or choose an option below.
|
|
15
|
+
-> Start the adventure &css{backgroundColor: #4a9eff; color: white;}
|
|
16
|
+
Narrator: Great! Let's begin your journey.
|
|
17
|
+
<<jump NextScene>>
|
|
18
|
+
-> Learn more &css{backgroundColor: #2ecc71; color: red;}
|
|
19
|
+
Narrator: Yarn Spinner is a powerful narrative scripting language.
|
|
20
|
+
npc: It supports variables, conditions, and branching stories.
|
|
21
|
+
<<jump NextScene>>
|
|
22
|
+
===
|
|
23
|
+
|
|
24
|
+
title: NextScene
|
|
25
|
+
---
|
|
26
|
+
blablabla
|
|
27
|
+
Narrator: You've reached the next scene!
|
|
28
|
+
Narrator: The dialogue system supports rich features like:
|
|
29
|
+
Narrator: • Variables and expressions
|
|
30
|
+
Narrator: • Conditional branching
|
|
31
|
+
Narrator: • Options with CSS styling
|
|
32
|
+
Narrator: • Commands and functions
|
|
33
|
+
Narrator: This is the end of the demo. Refresh to start again!
|
|
34
|
+
===`;
|
|
35
|
+
|
|
36
|
+
const DEFAULT_SCENES = `
|
|
37
|
+
scenes:
|
|
38
|
+
scene1: https://i.pinimg.com/1200x/73/f6/86/73f686e3c62e5982055ce34ed5c331b9.jpg
|
|
39
|
+
|
|
40
|
+
actors:
|
|
41
|
+
user: https://i.pinimg.com/1200x/d3/ed/cd/d3edcd8574301cf78f5e93ecca57e18b.jpg
|
|
42
|
+
Narrator: https://i.pinimg.com/1200x/ad/8d/f4/ad8df4186827c20ba5bdb98883e12262.jpg
|
|
43
|
+
npc: https://i.pinimg.com/1200x/81/12/1c/81121c69ef3e5bf657a7bacd9ff9d08e.jpg
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
export function DialogueExample() {
|
|
47
|
+
const [yarnText, setYarnText] = useState(DEFAULT_YARN);
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
|
|
50
|
+
const scenes: SceneCollection = useMemo(() => {
|
|
51
|
+
try {
|
|
52
|
+
return parseScenes(DEFAULT_SCENES);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
console.warn("Failed to parse scenes:", e);
|
|
55
|
+
return { scenes: {} };
|
|
56
|
+
}
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const program = useMemo(() => {
|
|
60
|
+
try {
|
|
61
|
+
setError(null);
|
|
62
|
+
const ast = parseYarn(yarnText);
|
|
63
|
+
return compile(ast);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}, [yarnText]);
|
|
69
|
+
|
|
70
|
+
const { result, advance } = useYarnRunner(
|
|
71
|
+
program || { nodes: {}, enums: {} },
|
|
72
|
+
{
|
|
73
|
+
startAt: "Start",
|
|
74
|
+
variables: {},
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
style={{
|
|
81
|
+
minHeight: "100vh",
|
|
82
|
+
backgroundColor: "#1a1a2e",
|
|
83
|
+
padding: "20px",
|
|
84
|
+
display: "flex",
|
|
85
|
+
flexDirection: "column",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<div style={{ maxWidth: "1000px", width: "100%" }}>
|
|
90
|
+
<h1 style={{ color: "#ffffff", textAlign: "center", marginBottom: "30px" }}>yarn-spinner-ts Dialogue Demo</h1>
|
|
91
|
+
|
|
92
|
+
{error && (
|
|
93
|
+
<div
|
|
94
|
+
style={{
|
|
95
|
+
backgroundColor: "#ff4444",
|
|
96
|
+
color: "#ffffff",
|
|
97
|
+
padding: "16px",
|
|
98
|
+
borderRadius: "8px",
|
|
99
|
+
marginBottom: "20px",
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<strong>Error:</strong> {error}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<DialogueView result={result} onAdvance={advance} scenes={scenes} />
|
|
107
|
+
|
|
108
|
+
<details style={{ marginTop: "30px", color: "#ffffff" }}>
|
|
109
|
+
<summary style={{ cursor: "pointer", padding: "10px", backgroundColor: "rgba(74, 158, 255, 0.2)", borderRadius: "8px" }}>
|
|
110
|
+
Edit Yarn Script
|
|
111
|
+
</summary>
|
|
112
|
+
<textarea
|
|
113
|
+
value={yarnText}
|
|
114
|
+
onChange={(e) => setYarnText(e.target.value)}
|
|
115
|
+
style={{
|
|
116
|
+
width: "100%",
|
|
117
|
+
minHeight: "300px",
|
|
118
|
+
marginTop: "10px",
|
|
119
|
+
padding: "12px",
|
|
120
|
+
fontFamily: "monospace",
|
|
121
|
+
fontSize: "14px",
|
|
122
|
+
backgroundColor: "#2a2a3e",
|
|
123
|
+
color: "#ffffff",
|
|
124
|
+
border: "1px solid #4a9eff",
|
|
125
|
+
borderRadius: "8px",
|
|
126
|
+
}}
|
|
127
|
+
spellCheck={false}
|
|
128
|
+
/>
|
|
129
|
+
<button
|
|
130
|
+
onClick={() => window.location.reload()}
|
|
131
|
+
style={{
|
|
132
|
+
marginTop: "10px",
|
|
133
|
+
padding: "10px 20px",
|
|
134
|
+
backgroundColor: "#4a9eff",
|
|
135
|
+
color: "#ffffff",
|
|
136
|
+
border: "none",
|
|
137
|
+
borderRadius: "8px",
|
|
138
|
+
cursor: "pointer",
|
|
139
|
+
fontSize: "16px",
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
Reload to Restart
|
|
143
|
+
</button>
|
|
144
|
+
</details>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import type { SceneCollection, SceneConfig } from "../scene/types.js";
|
|
3
|
+
import "./dialogue.css";
|
|
4
|
+
|
|
5
|
+
export interface DialogueSceneProps {
|
|
6
|
+
sceneName?: string;
|
|
7
|
+
speaker?: string;
|
|
8
|
+
scenes: SceneCollection;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Visual scene component that displays background and actor images
|
|
14
|
+
*/
|
|
15
|
+
export function DialogueScene({ sceneName, speaker, scenes, className }: DialogueSceneProps) {
|
|
16
|
+
const [currentBackground, setCurrentBackground] = useState<string | null>(null);
|
|
17
|
+
const [backgroundOpacity, setBackgroundOpacity] = useState(1);
|
|
18
|
+
const [nextBackground, setNextBackground] = useState<string | null>(null);
|
|
19
|
+
const [lastSceneName, setLastSceneName] = useState<string | undefined>(undefined);
|
|
20
|
+
|
|
21
|
+
// Get scene config - use last scene if current node has no scene
|
|
22
|
+
const activeSceneName = sceneName || lastSceneName;
|
|
23
|
+
const sceneConfig: SceneConfig | undefined = activeSceneName ? scenes.scenes[activeSceneName] : undefined;
|
|
24
|
+
const backgroundImage = sceneConfig?.background;
|
|
25
|
+
|
|
26
|
+
// Get all actors from the current scene
|
|
27
|
+
const sceneActors = sceneConfig ? Object.keys(sceneConfig.actors) : [];
|
|
28
|
+
|
|
29
|
+
// Handle background transitions
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (backgroundImage && backgroundImage !== currentBackground) {
|
|
32
|
+
if (currentBackground === null) {
|
|
33
|
+
// First background - set immediately
|
|
34
|
+
setCurrentBackground(backgroundImage);
|
|
35
|
+
setBackgroundOpacity(1);
|
|
36
|
+
if (sceneName) setLastSceneName(sceneName);
|
|
37
|
+
} else {
|
|
38
|
+
// Transition: fade out, change, fade in
|
|
39
|
+
setBackgroundOpacity(0);
|
|
40
|
+
setTimeout(() => {
|
|
41
|
+
setNextBackground(backgroundImage);
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
setCurrentBackground(backgroundImage);
|
|
44
|
+
setNextBackground(null);
|
|
45
|
+
setBackgroundOpacity(1);
|
|
46
|
+
if (sceneName) setLastSceneName(sceneName);
|
|
47
|
+
}, 50);
|
|
48
|
+
}, 300); // Half of transition duration
|
|
49
|
+
}
|
|
50
|
+
} else if (sceneName && sceneName !== lastSceneName) {
|
|
51
|
+
// New scene name set, update tracking
|
|
52
|
+
setLastSceneName(sceneName);
|
|
53
|
+
}
|
|
54
|
+
// Never clear background - keep it until a new one is explicitly set
|
|
55
|
+
}, [backgroundImage, currentBackground, sceneName, lastSceneName]);
|
|
56
|
+
|
|
57
|
+
// Default background color when no scene
|
|
58
|
+
const defaultBgColor = "rgba(26, 26, 46, 1)"; // Dark blue-purple
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
className={`yd-scene ${className || ""}`}
|
|
63
|
+
style={{
|
|
64
|
+
backgroundColor: currentBackground ? undefined : defaultBgColor,
|
|
65
|
+
backgroundImage: currentBackground ? `url(${currentBackground})` : undefined,
|
|
66
|
+
opacity: backgroundOpacity,
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{/* Next background (during transition) */}
|
|
70
|
+
{nextBackground && (
|
|
71
|
+
<div
|
|
72
|
+
className="yd-scene-next"
|
|
73
|
+
style={{
|
|
74
|
+
backgroundImage: `url(${nextBackground})`,
|
|
75
|
+
opacity: 1 - backgroundOpacity,
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{/* Actor image - show only the speaking actor, aligned to top */}
|
|
81
|
+
{sceneConfig && speaker && (() => {
|
|
82
|
+
// Find the actor that matches the speaker (case-insensitive)
|
|
83
|
+
const speakingActorName = sceneActors.find(
|
|
84
|
+
actorName => actorName.toLowerCase() === speaker.toLowerCase()
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (!speakingActorName) return null;
|
|
88
|
+
|
|
89
|
+
const actorConfig = sceneConfig.actors[speakingActorName];
|
|
90
|
+
if (!actorConfig?.image) return null;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<img
|
|
94
|
+
key={speakingActorName}
|
|
95
|
+
className="yd-actor"
|
|
96
|
+
src={actorConfig.image}
|
|
97
|
+
alt={speakingActorName}
|
|
98
|
+
onError={(e) => {
|
|
99
|
+
console.error(`Failed to load actor image for ${speakingActorName}:`, actorConfig.image, e);
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
})()}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|