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.
Files changed (114) hide show
  1. package/README.md +180 -0
  2. package/dist/compile/compiler.d.ts +9 -0
  3. package/dist/compile/compiler.js +172 -0
  4. package/dist/compile/compiler.js.map +1 -0
  5. package/dist/compile/ir.d.ts +47 -0
  6. package/dist/compile/ir.js +2 -0
  7. package/dist/compile/ir.js.map +1 -0
  8. package/dist/index.d.ts +10 -0
  9. package/dist/index.js +11 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/model/ast.d.ts +72 -0
  12. package/dist/model/ast.js +2 -0
  13. package/dist/model/ast.js.map +1 -0
  14. package/dist/parse/lexer.d.ts +7 -0
  15. package/dist/parse/lexer.js +78 -0
  16. package/dist/parse/lexer.js.map +1 -0
  17. package/dist/parse/parser.d.ts +4 -0
  18. package/dist/parse/parser.js +433 -0
  19. package/dist/parse/parser.js.map +1 -0
  20. package/dist/runtime/commands.d.ts +31 -0
  21. package/dist/runtime/commands.js +157 -0
  22. package/dist/runtime/commands.js.map +1 -0
  23. package/dist/runtime/evaluator.d.ts +52 -0
  24. package/dist/runtime/evaluator.js +309 -0
  25. package/dist/runtime/evaluator.js.map +1 -0
  26. package/dist/runtime/results.d.ts +22 -0
  27. package/dist/runtime/results.js +2 -0
  28. package/dist/runtime/results.js.map +1 -0
  29. package/dist/runtime/runner.d.ts +63 -0
  30. package/dist/runtime/runner.js +456 -0
  31. package/dist/runtime/runner.js.map +1 -0
  32. package/dist/tests/full_featured.test.d.ts +1 -0
  33. package/dist/tests/full_featured.test.js +130 -0
  34. package/dist/tests/full_featured.test.js.map +1 -0
  35. package/dist/tests/index.test.d.ts +1 -0
  36. package/dist/tests/index.test.js +30 -0
  37. package/dist/tests/index.test.js.map +1 -0
  38. package/dist/tests/jump_detour.test.d.ts +1 -0
  39. package/dist/tests/jump_detour.test.js +47 -0
  40. package/dist/tests/jump_detour.test.js.map +1 -0
  41. package/dist/tests/nodes_lines.test.d.ts +1 -0
  42. package/dist/tests/nodes_lines.test.js +23 -0
  43. package/dist/tests/nodes_lines.test.js.map +1 -0
  44. package/dist/tests/once.test.d.ts +1 -0
  45. package/dist/tests/once.test.js +29 -0
  46. package/dist/tests/once.test.js.map +1 -0
  47. package/dist/tests/options.test.d.ts +1 -0
  48. package/dist/tests/options.test.js +32 -0
  49. package/dist/tests/options.test.js.map +1 -0
  50. package/dist/tests/variables_flow_cmds.test.d.ts +1 -0
  51. package/dist/tests/variables_flow_cmds.test.js +30 -0
  52. package/dist/tests/variables_flow_cmds.test.js.map +1 -0
  53. package/dist/types.d.ts +3 -0
  54. package/dist/types.js +2 -0
  55. package/dist/types.js.map +1 -0
  56. package/docs/commands.md +21 -0
  57. package/docs/compatibility-checklist.md +77 -0
  58. package/docs/css-attribute.md +47 -0
  59. package/docs/detour.md +24 -0
  60. package/docs/enums.md +25 -0
  61. package/docs/flow-control.md +25 -0
  62. package/docs/functions.md +20 -0
  63. package/docs/jumps.md +24 -0
  64. package/docs/line-groups.md +21 -0
  65. package/docs/lines-nodes-and-options.md +30 -0
  66. package/docs/logic-and-variables.md +25 -0
  67. package/docs/markup.md +19 -0
  68. package/docs/node-groups.md +13 -0
  69. package/docs/once.md +21 -0
  70. package/docs/options.md +45 -0
  71. package/docs/saliency.md +25 -0
  72. package/docs/scenes-actors-setup.md +195 -0
  73. package/docs/scenes.md +64 -0
  74. package/docs/shadow-lines.md +18 -0
  75. package/docs/smart-variables.md +19 -0
  76. package/docs/storylets-and-saliency-a-primer.md +14 -0
  77. package/docs/tags-metadata.md +18 -0
  78. package/eslint.config.cjs +33 -0
  79. package/examples/browser/README.md +40 -0
  80. package/examples/browser/index.html +23 -0
  81. package/examples/browser/main.tsx +16 -0
  82. package/examples/browser/vite.config.ts +22 -0
  83. package/examples/react/DialogueExample.tsx +2 -0
  84. package/examples/react/DialogueView.tsx +2 -0
  85. package/examples/react/useYarnRunner.tsx +2 -0
  86. package/examples/scenes/scenes.yaml +10 -0
  87. package/examples/yarn/full_featured.yarn +43 -0
  88. package/package.json +55 -0
  89. package/src/compile/compiler.ts +183 -0
  90. package/src/compile/ir.ts +28 -0
  91. package/src/index.ts +17 -0
  92. package/src/model/ast.ts +93 -0
  93. package/src/parse/lexer.ts +108 -0
  94. package/src/parse/parser.ts +435 -0
  95. package/src/react/DialogueExample.tsx +149 -0
  96. package/src/react/DialogueScene.tsx +107 -0
  97. package/src/react/DialogueView.tsx +160 -0
  98. package/src/react/dialogue.css +181 -0
  99. package/src/react/useYarnRunner.tsx +33 -0
  100. package/src/runtime/commands.ts +183 -0
  101. package/src/runtime/evaluator.ts +327 -0
  102. package/src/runtime/results.ts +27 -0
  103. package/src/runtime/runner.ts +480 -0
  104. package/src/scene/parser.ts +83 -0
  105. package/src/scene/types.ts +17 -0
  106. package/src/tests/full_featured.test.ts +131 -0
  107. package/src/tests/index.test.ts +34 -0
  108. package/src/tests/jump_detour.test.ts +47 -0
  109. package/src/tests/nodes_lines.test.ts +27 -0
  110. package/src/tests/once.test.ts +32 -0
  111. package/src/tests/options.test.ts +34 -0
  112. package/src/tests/variables_flow_cmds.test.ts +33 -0
  113. package/src/types.ts +4 -0
  114. 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
+