yarn-spinner-runner-ts 0.1.2 → 0.1.4
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 +102 -88
- package/dist/compile/compiler.js +4 -4
- package/dist/compile/compiler.js.map +1 -1
- package/dist/compile/ir.d.ts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/markup/parser.d.ts +3 -0
- package/dist/markup/parser.js +332 -0
- package/dist/markup/parser.js.map +1 -0
- package/dist/markup/types.d.ts +17 -0
- package/dist/markup/types.js +2 -0
- package/dist/markup/types.js.map +1 -0
- package/dist/model/ast.d.ts +3 -0
- package/dist/parse/parser.js +57 -8
- package/dist/parse/parser.js.map +1 -1
- package/dist/react/DialogueExample.js +6 -4
- package/dist/react/DialogueExample.js.map +1 -1
- package/dist/react/DialogueScene.d.ts +2 -1
- package/dist/react/DialogueScene.js +95 -26
- package/dist/react/DialogueScene.js.map +1 -1
- package/dist/react/DialogueView.d.ts +10 -1
- package/dist/react/DialogueView.js +68 -5
- package/dist/react/DialogueView.js.map +1 -1
- package/dist/react/MarkupRenderer.d.ts +8 -0
- package/dist/react/MarkupRenderer.js +64 -0
- package/dist/react/MarkupRenderer.js.map +1 -0
- package/dist/react/TypingText.d.ts +14 -0
- package/dist/react/TypingText.js +78 -0
- package/dist/react/TypingText.js.map +1 -0
- package/dist/runtime/commands.js +12 -1
- package/dist/runtime/commands.js.map +1 -1
- package/dist/runtime/results.d.ts +3 -0
- package/dist/runtime/runner.d.ts +7 -0
- package/dist/runtime/runner.js +161 -14
- package/dist/runtime/runner.js.map +1 -1
- package/dist/tests/custom_functions.test.d.ts +1 -0
- package/dist/tests/custom_functions.test.js +129 -0
- package/dist/tests/custom_functions.test.js.map +1 -0
- package/dist/tests/markup.test.d.ts +1 -0
- package/dist/tests/markup.test.js +46 -0
- package/dist/tests/markup.test.js.map +1 -0
- package/dist/tests/nodes_lines.test.js +25 -1
- package/dist/tests/nodes_lines.test.js.map +1 -1
- package/dist/tests/options.test.js +30 -1
- package/dist/tests/options.test.js.map +1 -1
- package/dist/tests/story_end.test.d.ts +1 -0
- package/dist/tests/story_end.test.js +37 -0
- package/dist/tests/story_end.test.js.map +1 -0
- package/dist/tests/typing-text.test.d.ts +1 -0
- package/dist/tests/typing-text.test.js +12 -0
- package/dist/tests/typing-text.test.js.map +1 -0
- package/docs/actor-transition.md +34 -0
- package/docs/markup.md +34 -19
- package/docs/scenes-actors-setup.md +1 -0
- package/docs/typing-animation.md +44 -0
- package/eslint.config.cjs +3 -0
- package/examples/browser/index.html +1 -1
- package/examples/browser/main.tsx +0 -2
- package/package.json +6 -6
- package/src/compile/compiler.ts +4 -4
- package/src/compile/ir.ts +3 -2
- package/src/index.ts +3 -0
- package/src/markup/parser.ts +372 -0
- package/src/markup/types.ts +22 -0
- package/src/model/ast.ts +17 -13
- package/src/parse/parser.ts +60 -8
- package/src/react/DialogueExample.tsx +18 -42
- package/src/react/DialogueScene.tsx +143 -44
- package/src/react/DialogueView.tsx +122 -8
- package/src/react/MarkupRenderer.tsx +110 -0
- package/src/react/TypingText.tsx +127 -0
- package/src/react/dialogue.css +26 -13
- package/src/runtime/commands.ts +14 -1
- package/src/runtime/results.ts +3 -1
- package/src/runtime/runner.ts +170 -14
- package/src/tests/custom_functions.test.ts +140 -0
- package/src/tests/markup.test.ts +62 -0
- package/src/tests/nodes_lines.test.ts +35 -1
- package/src/tests/options.test.ts +39 -1
- package/src/tests/story_end.test.ts +42 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import type { MarkupParseResult, MarkupSegment, MarkupValue, MarkupWrapper } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_HTML_TAGS = new Set(["b", "em", "small", "strong", "sub", "sup", "ins", "del", "mark"]);
|
|
4
|
+
|
|
5
|
+
interface StackEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
type: MarkupWrapper["type"];
|
|
8
|
+
properties: Record<string, MarkupValue>;
|
|
9
|
+
originalText: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ParsedTag {
|
|
13
|
+
kind: "open" | "close" | "self";
|
|
14
|
+
name: string;
|
|
15
|
+
properties: Record<string, MarkupValue>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SELF_CLOSING_SPACE_REGEX = /\s+\/$/;
|
|
19
|
+
const ATTRIBUTE_REGEX =
|
|
20
|
+
/^([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/;
|
|
21
|
+
|
|
22
|
+
export function parseMarkup(input: string): MarkupParseResult {
|
|
23
|
+
const segments: MarkupSegment[] = [];
|
|
24
|
+
const stack: StackEntry[] = [];
|
|
25
|
+
const chars: string[] = [];
|
|
26
|
+
let currentSegment: MarkupSegment | null = null;
|
|
27
|
+
let nomarkupDepth = 0;
|
|
28
|
+
|
|
29
|
+
const pushSegment = (segment: MarkupSegment) => {
|
|
30
|
+
if (segment.selfClosing || segment.end > segment.start) {
|
|
31
|
+
segments.push(segment);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const wrappersEqual = (a: MarkupWrapper[], b: MarkupWrapper[]) => {
|
|
36
|
+
if (a.length !== b.length) return false;
|
|
37
|
+
for (let i = 0; i < a.length; i++) {
|
|
38
|
+
const wa = a[i];
|
|
39
|
+
const wb = b[i];
|
|
40
|
+
if (wa.name !== wb.name || wa.type !== wb.type) return false;
|
|
41
|
+
const keysA = Object.keys(wa.properties);
|
|
42
|
+
const keysB = Object.keys(wb.properties);
|
|
43
|
+
if (keysA.length !== keysB.length) return false;
|
|
44
|
+
for (const key of keysA) {
|
|
45
|
+
if (wa.properties[key] !== wb.properties[key]) return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const flushCurrentSegment = () => {
|
|
52
|
+
if (currentSegment) {
|
|
53
|
+
segments.push(currentSegment);
|
|
54
|
+
currentSegment = null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const cloneWrappers = (): MarkupWrapper[] =>
|
|
59
|
+
stack.map((entry) => ({
|
|
60
|
+
name: entry.name,
|
|
61
|
+
type: entry.type,
|
|
62
|
+
properties: { ...entry.properties },
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
const appendChar = (char: string) => {
|
|
66
|
+
const index = chars.length;
|
|
67
|
+
chars.push(char);
|
|
68
|
+
const wrappers = cloneWrappers();
|
|
69
|
+
if (currentSegment && wrappersEqual(currentSegment.wrappers, wrappers)) {
|
|
70
|
+
currentSegment.end = index + 1;
|
|
71
|
+
} else {
|
|
72
|
+
flushCurrentSegment();
|
|
73
|
+
currentSegment = {
|
|
74
|
+
start: index,
|
|
75
|
+
end: index + 1,
|
|
76
|
+
wrappers,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const appendLiteral = (literal: string) => {
|
|
82
|
+
for (const ch of literal) {
|
|
83
|
+
appendChar(ch);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const parseTag = (contentRaw: string): ParsedTag | null => {
|
|
88
|
+
let content = contentRaw.trim();
|
|
89
|
+
if (!content) return null;
|
|
90
|
+
|
|
91
|
+
if (content.startsWith("/")) {
|
|
92
|
+
const name = content.slice(1).trim().toLowerCase();
|
|
93
|
+
if (!name) return null;
|
|
94
|
+
return { kind: "close", name, properties: {} };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let kind: ParsedTag["kind"] = "open";
|
|
98
|
+
if (content.endsWith("/")) {
|
|
99
|
+
content = content.replace(SELF_CLOSING_SPACE_REGEX, "").trim();
|
|
100
|
+
if (content.endsWith("/")) {
|
|
101
|
+
content = content.slice(0, -1).trim();
|
|
102
|
+
}
|
|
103
|
+
kind = "self";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const nameMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/);
|
|
107
|
+
if (!nameMatch) return null;
|
|
108
|
+
const name = nameMatch[1].toLowerCase();
|
|
109
|
+
let rest = content.slice(nameMatch[0].length).trim();
|
|
110
|
+
|
|
111
|
+
const properties: Record<string, MarkupValue> = {};
|
|
112
|
+
while (rest.length > 0) {
|
|
113
|
+
const attrMatch = rest.match(ATTRIBUTE_REGEX);
|
|
114
|
+
if (!attrMatch) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
const [, keyRaw, doubleQuoted, singleQuoted, bare] = attrMatch;
|
|
118
|
+
const key = keyRaw.toLowerCase();
|
|
119
|
+
let value: MarkupValue = true;
|
|
120
|
+
const rawValue = doubleQuoted ?? singleQuoted ?? bare;
|
|
121
|
+
if (rawValue !== undefined) {
|
|
122
|
+
value = parseAttributeValue(rawValue);
|
|
123
|
+
}
|
|
124
|
+
properties[key] = value;
|
|
125
|
+
rest = rest.slice(attrMatch[0].length).trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { kind, name, properties };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const parseAttributeValue = (raw: string): MarkupValue => {
|
|
132
|
+
const trimmed = raw.trim();
|
|
133
|
+
if (/^(true|false)$/i.test(trimmed)) {
|
|
134
|
+
return /^true$/i.test(trimmed);
|
|
135
|
+
}
|
|
136
|
+
if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
|
|
137
|
+
const num = Number(trimmed);
|
|
138
|
+
if (!Number.isNaN(num)) {
|
|
139
|
+
return num;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return trimmed;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const handleSelfClosing = (tag: ParsedTag) => {
|
|
146
|
+
const wrapper: MarkupWrapper = {
|
|
147
|
+
name: tag.name,
|
|
148
|
+
type: DEFAULT_HTML_TAGS.has(tag.name) ? "default" : "custom",
|
|
149
|
+
properties: tag.properties,
|
|
150
|
+
};
|
|
151
|
+
const position = chars.length;
|
|
152
|
+
pushSegment({
|
|
153
|
+
start: position,
|
|
154
|
+
end: position,
|
|
155
|
+
wrappers: [wrapper],
|
|
156
|
+
selfClosing: true,
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
let i = 0;
|
|
161
|
+
while (i < input.length) {
|
|
162
|
+
const char = input[i];
|
|
163
|
+
if (char === "\\" && i + 1 < input.length) {
|
|
164
|
+
const next = input[i + 1];
|
|
165
|
+
if (next === "[" || next === "]" || next === "\\") {
|
|
166
|
+
appendChar(next);
|
|
167
|
+
i += 2;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (char === "[") {
|
|
173
|
+
const closeIndex = findClosingBracket(input, i + 1);
|
|
174
|
+
if (closeIndex === -1) {
|
|
175
|
+
appendChar(char);
|
|
176
|
+
i += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const content = input.slice(i + 1, closeIndex);
|
|
180
|
+
const originalText = input.slice(i, closeIndex + 1);
|
|
181
|
+
|
|
182
|
+
const parsed = parseTag(content);
|
|
183
|
+
if (!parsed) {
|
|
184
|
+
appendLiteral(originalText);
|
|
185
|
+
i = closeIndex + 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (parsed.name === "nomarkup") {
|
|
190
|
+
if (parsed.kind === "open") {
|
|
191
|
+
nomarkupDepth += 1;
|
|
192
|
+
} else if (parsed.kind === "close" && nomarkupDepth > 0) {
|
|
193
|
+
nomarkupDepth -= 1;
|
|
194
|
+
}
|
|
195
|
+
i = closeIndex + 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (nomarkupDepth > 0) {
|
|
200
|
+
appendLiteral(originalText);
|
|
201
|
+
i = closeIndex + 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (parsed.kind === "open") {
|
|
206
|
+
const entry: StackEntry = {
|
|
207
|
+
name: parsed.name,
|
|
208
|
+
type: DEFAULT_HTML_TAGS.has(parsed.name) ? "default" : "custom",
|
|
209
|
+
properties: parsed.properties,
|
|
210
|
+
originalText,
|
|
211
|
+
};
|
|
212
|
+
stack.push(entry);
|
|
213
|
+
flushCurrentSegment();
|
|
214
|
+
i = closeIndex + 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (parsed.kind === "self") {
|
|
219
|
+
handleSelfClosing(parsed);
|
|
220
|
+
i = closeIndex + 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// closing tag
|
|
225
|
+
if (stack.length === 0) {
|
|
226
|
+
appendLiteral(originalText);
|
|
227
|
+
i = closeIndex + 1;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const top = stack[stack.length - 1];
|
|
231
|
+
if (top.name === parsed.name) {
|
|
232
|
+
flushCurrentSegment();
|
|
233
|
+
stack.pop();
|
|
234
|
+
i = closeIndex + 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// mismatched closing; treat as literal
|
|
238
|
+
appendLiteral(originalText);
|
|
239
|
+
i = closeIndex + 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
appendChar(char);
|
|
244
|
+
i += 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
flushCurrentSegment();
|
|
248
|
+
|
|
249
|
+
// If any tags remain open, treat them as literal text appended at end
|
|
250
|
+
while (stack.length > 0) {
|
|
251
|
+
const entry = stack.pop()!;
|
|
252
|
+
appendLiteral(entry.originalText);
|
|
253
|
+
}
|
|
254
|
+
flushCurrentSegment();
|
|
255
|
+
|
|
256
|
+
const text = chars.join("");
|
|
257
|
+
return {
|
|
258
|
+
text,
|
|
259
|
+
segments: mergeSegments(segments, text.length),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function mergeSegments(segments: MarkupSegment[], textLength: number): MarkupSegment[] {
|
|
264
|
+
const sorted = [...segments].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
265
|
+
const merged: MarkupSegment[] = [];
|
|
266
|
+
let last: MarkupSegment | null = null;
|
|
267
|
+
|
|
268
|
+
for (const seg of sorted) {
|
|
269
|
+
if (seg.start === seg.end && !seg.selfClosing) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (last && !seg.selfClosing && last.end === seg.start && wrappersMatch(last.wrappers, seg.wrappers)) {
|
|
273
|
+
last.end = seg.end;
|
|
274
|
+
} else {
|
|
275
|
+
last = {
|
|
276
|
+
start: seg.start,
|
|
277
|
+
end: seg.end,
|
|
278
|
+
wrappers: seg.wrappers,
|
|
279
|
+
selfClosing: seg.selfClosing,
|
|
280
|
+
};
|
|
281
|
+
merged.push(last);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (merged.length === 0 && textLength > 0) {
|
|
286
|
+
merged.push({
|
|
287
|
+
start: 0,
|
|
288
|
+
end: textLength,
|
|
289
|
+
wrappers: [],
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return merged;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function wrappersMatch(a: MarkupWrapper[], b: MarkupWrapper[]): boolean {
|
|
297
|
+
if (a.length !== b.length) return false;
|
|
298
|
+
for (let i = 0; i < a.length; i++) {
|
|
299
|
+
if (a[i].name !== b[i].name || a[i].type !== b[i].type) return false;
|
|
300
|
+
const keysA = Object.keys(a[i].properties);
|
|
301
|
+
const keysB = Object.keys(b[i].properties);
|
|
302
|
+
if (keysA.length !== keysB.length) return false;
|
|
303
|
+
for (const key of keysA) {
|
|
304
|
+
if (a[i].properties[key] !== b[i].properties[key]) return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function findClosingBracket(text: string, start: number): number {
|
|
311
|
+
for (let i = start; i < text.length; i++) {
|
|
312
|
+
if (text[i] === "]") {
|
|
313
|
+
let backslashCount = 0;
|
|
314
|
+
let j = i - 1;
|
|
315
|
+
while (j >= 0 && text[j] === "\\") {
|
|
316
|
+
backslashCount++;
|
|
317
|
+
j--;
|
|
318
|
+
}
|
|
319
|
+
if (backslashCount % 2 === 0) {
|
|
320
|
+
return i;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return -1;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function sliceMarkup(result: MarkupParseResult, start: number, end?: number): MarkupParseResult {
|
|
328
|
+
const textLength = result.text.length;
|
|
329
|
+
const sliceStart = Math.max(0, Math.min(start, textLength));
|
|
330
|
+
const sliceEnd = end === undefined ? textLength : Math.max(sliceStart, Math.min(end, textLength));
|
|
331
|
+
const slicedSegments: MarkupSegment[] = [];
|
|
332
|
+
|
|
333
|
+
for (const seg of result.segments) {
|
|
334
|
+
const segStart = Math.max(seg.start, sliceStart);
|
|
335
|
+
const segEnd = Math.min(seg.end, sliceEnd);
|
|
336
|
+
if (seg.selfClosing) {
|
|
337
|
+
if (segStart >= sliceStart && segStart <= sliceEnd) {
|
|
338
|
+
slicedSegments.push({
|
|
339
|
+
start: segStart - sliceStart,
|
|
340
|
+
end: segStart - sliceStart,
|
|
341
|
+
wrappers: seg.wrappers,
|
|
342
|
+
selfClosing: true,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (segEnd <= segStart) continue;
|
|
348
|
+
slicedSegments.push({
|
|
349
|
+
start: segStart - sliceStart,
|
|
350
|
+
end: segEnd - sliceStart,
|
|
351
|
+
wrappers: seg.wrappers.map((wrapper) => ({
|
|
352
|
+
name: wrapper.name,
|
|
353
|
+
type: wrapper.type,
|
|
354
|
+
properties: { ...wrapper.properties },
|
|
355
|
+
})),
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (slicedSegments.length === 0 && sliceEnd - sliceStart > 0) {
|
|
360
|
+
slicedSegments.push({
|
|
361
|
+
start: 0,
|
|
362
|
+
end: sliceEnd - sliceStart,
|
|
363
|
+
wrappers: [],
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
text: result.text.slice(sliceStart, sliceEnd),
|
|
369
|
+
segments: mergeSegments(slicedSegments, sliceEnd - sliceStart),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type MarkupValue = string | number | boolean;
|
|
2
|
+
|
|
3
|
+
export type MarkupWrapperType = "default" | "custom";
|
|
4
|
+
|
|
5
|
+
export interface MarkupWrapper {
|
|
6
|
+
name: string;
|
|
7
|
+
type: MarkupWrapperType;
|
|
8
|
+
properties: Record<string, MarkupValue>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MarkupSegment {
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
wrappers: MarkupWrapper[];
|
|
15
|
+
selfClosing?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MarkupParseResult {
|
|
19
|
+
text: string;
|
|
20
|
+
segments: MarkupSegment[];
|
|
21
|
+
}
|
|
22
|
+
|
package/src/model/ast.ts
CHANGED
|
@@ -36,12 +36,15 @@ export type Statement =
|
|
|
36
36
|
| Detour
|
|
37
37
|
| EnumBlock;
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
import type { MarkupParseResult } from "../markup/types.js";
|
|
40
|
+
|
|
41
|
+
export interface Line {
|
|
42
|
+
type: "Line";
|
|
43
|
+
speaker?: string;
|
|
44
|
+
text: string;
|
|
45
|
+
tags?: string[];
|
|
46
|
+
markup?: MarkupParseResult;
|
|
47
|
+
}
|
|
45
48
|
|
|
46
49
|
export interface Command {
|
|
47
50
|
type: "Command";
|
|
@@ -63,13 +66,14 @@ export interface OptionGroup {
|
|
|
63
66
|
options: Option[];
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
export interface Option {
|
|
67
|
-
type: "Option";
|
|
68
|
-
text: string;
|
|
69
|
-
body: Statement[]; // executed if chosen
|
|
70
|
-
tags?: string[];
|
|
71
|
-
css?: string; // Custom CSS style for option
|
|
72
|
-
|
|
69
|
+
export interface Option {
|
|
70
|
+
type: "Option";
|
|
71
|
+
text: string;
|
|
72
|
+
body: Statement[]; // executed if chosen
|
|
73
|
+
tags?: string[];
|
|
74
|
+
css?: string; // Custom CSS style for option
|
|
75
|
+
markup?: MarkupParseResult;
|
|
76
|
+
}
|
|
73
77
|
|
|
74
78
|
export interface IfBlock {
|
|
75
79
|
type: "If";
|
package/src/parse/parser.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { lex, Token } from "./lexer.js";
|
|
2
|
+
import { parseMarkup, sliceMarkup } from "../markup/parser.js";
|
|
3
|
+
import type { MarkupParseResult } from "../markup/types.js";
|
|
2
4
|
import type {
|
|
3
5
|
YarnDocument,
|
|
4
6
|
YarnNode,
|
|
@@ -177,17 +179,33 @@ class Parser {
|
|
|
177
179
|
}
|
|
178
180
|
if (t.type === "TEXT") {
|
|
179
181
|
const raw = this.take("TEXT").text;
|
|
180
|
-
const { cleanText:
|
|
181
|
-
const
|
|
182
|
+
const { cleanText: textWithoutTags, tags } = this.extractTags(raw);
|
|
183
|
+
const markup = parseMarkup(textWithoutTags);
|
|
184
|
+
const speakerMatch = markup.text.match(/^([^:\s][^:]*)\s*:\s*(.*)$/);
|
|
182
185
|
if (speakerMatch) {
|
|
183
|
-
|
|
186
|
+
const messageText = speakerMatch[2];
|
|
187
|
+
const messageOffset = markup.text.length - messageText.length;
|
|
188
|
+
const slicedMarkup = sliceMarkup(markup, messageOffset);
|
|
189
|
+
const normalizedMarkup = this.normalizeMarkup(slicedMarkup);
|
|
190
|
+
return {
|
|
191
|
+
type: "Line",
|
|
192
|
+
speaker: speakerMatch[1].trim(),
|
|
193
|
+
text: messageText,
|
|
194
|
+
tags,
|
|
195
|
+
markup: normalizedMarkup,
|
|
196
|
+
} as Line;
|
|
184
197
|
}
|
|
185
198
|
// If/Else blocks use inline markup {if ...}
|
|
186
|
-
const trimmed = text.trim();
|
|
199
|
+
const trimmed = markup.text.trim();
|
|
187
200
|
if (trimmed.startsWith("{if ") || trimmed === "{else}" || trimmed.startsWith("{else if ") || trimmed === "{endif}") {
|
|
188
|
-
return this.parseIfFromText(text);
|
|
201
|
+
return this.parseIfFromText(markup.text);
|
|
189
202
|
}
|
|
190
|
-
return {
|
|
203
|
+
return {
|
|
204
|
+
type: "Line",
|
|
205
|
+
text: markup.text,
|
|
206
|
+
tags,
|
|
207
|
+
markup: this.normalizeMarkup(markup),
|
|
208
|
+
} as Line;
|
|
191
209
|
}
|
|
192
210
|
throw new ParseError(`Unexpected token ${t.type}`);
|
|
193
211
|
}
|
|
@@ -198,7 +216,8 @@ class Parser {
|
|
|
198
216
|
while (this.at("OPTION")) {
|
|
199
217
|
const raw = this.take("OPTION").text;
|
|
200
218
|
const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
|
|
201
|
-
const { text, css } = this.extractCss(textWithAttrs);
|
|
219
|
+
const { text: textWithoutCss, css } = this.extractCss(textWithAttrs);
|
|
220
|
+
const markup = parseMarkup(textWithoutCss);
|
|
202
221
|
let body: Statement[] = [];
|
|
203
222
|
if (this.at("INDENT")) {
|
|
204
223
|
this.take("INDENT");
|
|
@@ -206,13 +225,46 @@ class Parser {
|
|
|
206
225
|
this.take("DEDENT");
|
|
207
226
|
while (this.at("EMPTY")) this.i++;
|
|
208
227
|
}
|
|
209
|
-
options.push({
|
|
228
|
+
options.push({
|
|
229
|
+
type: "Option",
|
|
230
|
+
text: markup.text,
|
|
231
|
+
body,
|
|
232
|
+
tags,
|
|
233
|
+
css,
|
|
234
|
+
markup: this.normalizeMarkup(markup),
|
|
235
|
+
});
|
|
210
236
|
// Consecutive options belong to the same group; break on non-OPTION
|
|
211
237
|
while (this.at("EMPTY")) this.i++;
|
|
212
238
|
}
|
|
213
239
|
return { type: "OptionGroup", options };
|
|
214
240
|
}
|
|
215
241
|
|
|
242
|
+
private normalizeMarkup(result: MarkupParseResult): MarkupParseResult | undefined {
|
|
243
|
+
if (!result) return undefined;
|
|
244
|
+
if (result.segments.length === 0) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
const hasFormatting = result.segments.some(
|
|
248
|
+
(segment) => segment.wrappers.length > 0 || segment.selfClosing
|
|
249
|
+
);
|
|
250
|
+
if (!hasFormatting) {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
text: result.text,
|
|
255
|
+
segments: result.segments.map((segment) => ({
|
|
256
|
+
start: segment.start,
|
|
257
|
+
end: segment.end,
|
|
258
|
+
wrappers: segment.wrappers.map((wrapper) => ({
|
|
259
|
+
name: wrapper.name,
|
|
260
|
+
type: wrapper.type,
|
|
261
|
+
properties: { ...wrapper.properties },
|
|
262
|
+
})),
|
|
263
|
+
selfClosing: segment.selfClosing,
|
|
264
|
+
})),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
216
268
|
private extractTags(input: string): { cleanText: string; tags?: string[] } {
|
|
217
269
|
const tags: string[] = [];
|
|
218
270
|
// Match tags that are space-separated and not part of hex colors or CSS
|
|
@@ -9,8 +9,9 @@ import type { SceneCollection } from "../scene/types.js";
|
|
|
9
9
|
const DEFAULT_YARN = `title: Start
|
|
10
10
|
scene: scene1
|
|
11
11
|
---
|
|
12
|
+
Narrator: [wave]hello[/wave] [b]hello[/b] baarter
|
|
12
13
|
Narrator: Welcome to yarn-spinner-ts!
|
|
13
|
-
|
|
14
|
+
npc: This is a dialogue system powered by Yarn Spinner.
|
|
14
15
|
Narrator: Click anywhere to continue, or choose an option below.
|
|
15
16
|
-> Start the adventure &css{backgroundColor: #4a9eff; color: white;}
|
|
16
17
|
Narrator: Great! Let's begin your journey.
|
|
@@ -23,7 +24,7 @@ Narrator: Click anywhere to continue, or choose an option below.
|
|
|
23
24
|
|
|
24
25
|
title: NextScene
|
|
25
26
|
---
|
|
26
|
-
blablabla
|
|
27
|
+
npc: blablabla
|
|
27
28
|
Narrator: You've reached the next scene!
|
|
28
29
|
Narrator: The dialogue system supports rich features like:
|
|
29
30
|
Narrator: • Variables and expressions
|
|
@@ -44,8 +45,9 @@ actors:
|
|
|
44
45
|
`;
|
|
45
46
|
|
|
46
47
|
export function DialogueExample() {
|
|
47
|
-
const [yarnText
|
|
48
|
+
const [yarnText] = useState(DEFAULT_YARN);
|
|
48
49
|
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
const enableTypingAnimation = true;
|
|
49
51
|
|
|
50
52
|
const scenes: SceneCollection = useMemo(() => {
|
|
51
53
|
try {
|
|
@@ -103,45 +105,19 @@ export function DialogueExample() {
|
|
|
103
105
|
</div>
|
|
104
106
|
)}
|
|
105
107
|
|
|
106
|
-
<DialogueView
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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> */}
|
|
108
|
+
<DialogueView
|
|
109
|
+
result={result}
|
|
110
|
+
onAdvance={advance}
|
|
111
|
+
scenes={scenes}
|
|
112
|
+
enableTypingAnimation={enableTypingAnimation}
|
|
113
|
+
showTypingCursor={true}
|
|
114
|
+
typingSpeed={20}
|
|
115
|
+
cursorCharacter="$"
|
|
116
|
+
autoAdvanceAfterTyping={true}
|
|
117
|
+
autoAdvanceDelay={2000}
|
|
118
|
+
actorTransitionDuration={1000}
|
|
119
|
+
pauseBeforeAdvance={enableTypingAnimation ? 1000 : 0}
|
|
120
|
+
/>
|
|
145
121
|
</div>
|
|
146
122
|
</div>
|
|
147
123
|
);
|