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,160 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { RuntimeResult } from "../runtime/results.js";
|
|
3
|
+
import { DialogueScene } from "./DialogueScene.js";
|
|
4
|
+
import type { SceneCollection } from "../scene/types.js";
|
|
5
|
+
import "./dialogue.css";
|
|
6
|
+
|
|
7
|
+
export interface DialogueViewProps {
|
|
8
|
+
result: RuntimeResult | null;
|
|
9
|
+
onAdvance: (optionIndex?: number) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
scenes?: SceneCollection;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Helper to parse CSS string into object
|
|
15
|
+
function parseCss(cssStr: string | undefined): React.CSSProperties {
|
|
16
|
+
if (!cssStr) return {};
|
|
17
|
+
const styles: React.CSSProperties = {};
|
|
18
|
+
// Improved parser: handles quoted values and commas
|
|
19
|
+
// Split by semicolon, but preserve quoted strings
|
|
20
|
+
const rules: string[] = [];
|
|
21
|
+
let currentRule = "";
|
|
22
|
+
let inQuotes = false;
|
|
23
|
+
let quoteChar = "";
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < cssStr.length; i++) {
|
|
26
|
+
const char = cssStr[i];
|
|
27
|
+
if ((char === '"' || char === "'") && !inQuotes) {
|
|
28
|
+
inQuotes = true;
|
|
29
|
+
quoteChar = char;
|
|
30
|
+
currentRule += char;
|
|
31
|
+
} else if (char === quoteChar && inQuotes) {
|
|
32
|
+
inQuotes = false;
|
|
33
|
+
quoteChar = "";
|
|
34
|
+
currentRule += char;
|
|
35
|
+
} else if (char === ";" && !inQuotes) {
|
|
36
|
+
rules.push(currentRule.trim());
|
|
37
|
+
currentRule = "";
|
|
38
|
+
} else {
|
|
39
|
+
currentRule += char;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (currentRule.trim()) {
|
|
43
|
+
rules.push(currentRule.trim());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
rules.forEach((rule) => {
|
|
47
|
+
if (!rule) return;
|
|
48
|
+
const colonIndex = rule.indexOf(":");
|
|
49
|
+
if (colonIndex === -1) return;
|
|
50
|
+
const prop = rule.slice(0, colonIndex).trim();
|
|
51
|
+
const value = rule.slice(colonIndex + 1).trim();
|
|
52
|
+
if (prop && value) {
|
|
53
|
+
// Convert kebab-case to camelCase
|
|
54
|
+
const camelProp = prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
|
55
|
+
// Remove quotes from value if present, and strip !important (React doesn't support it)
|
|
56
|
+
let cleanValue = value.trim();
|
|
57
|
+
if (cleanValue.endsWith("!important")) {
|
|
58
|
+
cleanValue = cleanValue.slice(0, -10).trim();
|
|
59
|
+
}
|
|
60
|
+
if (cleanValue.startsWith('"') && cleanValue.endsWith('"')) {
|
|
61
|
+
cleanValue = cleanValue.slice(1, -1);
|
|
62
|
+
} else if (cleanValue.startsWith("'") && cleanValue.endsWith("'")) {
|
|
63
|
+
cleanValue = cleanValue.slice(1, -1);
|
|
64
|
+
}
|
|
65
|
+
(styles as any)[camelProp] = cleanValue;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return styles;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function DialogueView({ result, onAdvance, className, scenes }: DialogueViewProps) {
|
|
72
|
+
const sceneName = result?.type === "text" || result?.type === "options" ? result.scene : undefined;
|
|
73
|
+
const speaker = result?.type === "text" ? result.speaker : undefined;
|
|
74
|
+
const sceneCollection = scenes || { scenes: {} };
|
|
75
|
+
|
|
76
|
+
if (!result) {
|
|
77
|
+
return (
|
|
78
|
+
<div className={`yd-empty ${className || ""}`}>
|
|
79
|
+
<p>Dialogue ended or not started.</p>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (result.type === "text") {
|
|
85
|
+
const nodeStyles = parseCss(result.nodeCss);
|
|
86
|
+
return (
|
|
87
|
+
<div className="yd-container">
|
|
88
|
+
<DialogueScene sceneName={sceneName} speaker={speaker} scenes={sceneCollection} />
|
|
89
|
+
<div
|
|
90
|
+
className={`yd-dialogue-box ${result.isDialogueEnd ? "yd-text-box-end" : ""} ${className || ""}`}
|
|
91
|
+
style={nodeStyles} // Only apply dynamic node CSS
|
|
92
|
+
onClick={() => !result.isDialogueEnd && onAdvance()}
|
|
93
|
+
>
|
|
94
|
+
<div className="yd-text-box">
|
|
95
|
+
{result.speaker && (
|
|
96
|
+
<div className="yd-speaker">
|
|
97
|
+
{result.speaker}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
<p className={`yd-text ${result.speaker ? "yd-text-with-speaker" : ""}`}>
|
|
101
|
+
{result.text || "\u00A0"}
|
|
102
|
+
</p>
|
|
103
|
+
{!result.isDialogueEnd && (
|
|
104
|
+
<div className="yd-continue">
|
|
105
|
+
▼
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (result.type === "options") {
|
|
115
|
+
const nodeStyles = parseCss(result.nodeCss);
|
|
116
|
+
return (
|
|
117
|
+
<div className="yd-container">
|
|
118
|
+
<DialogueScene sceneName={sceneName} speaker={speaker} scenes={sceneCollection} />
|
|
119
|
+
<div className={`yd-options-container ${className || ""}`}>
|
|
120
|
+
<div className="yd-options-box" style={nodeStyles}>
|
|
121
|
+
<div className="yd-options-title">Choose an option:</div>
|
|
122
|
+
<div className="yd-options-list">
|
|
123
|
+
{result.options.map((option, index) => {
|
|
124
|
+
const optionStyles = parseCss(option.css);
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
key={index}
|
|
128
|
+
className="yd-option-button"
|
|
129
|
+
onClick={() => onAdvance(index)}
|
|
130
|
+
style={optionStyles} // Only apply dynamic option CSS
|
|
131
|
+
>
|
|
132
|
+
{option.text}
|
|
133
|
+
</button>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Command result - auto-advance
|
|
144
|
+
if (result.type === "command") {
|
|
145
|
+
// Auto-advance commands after a brief moment
|
|
146
|
+
React.useEffect(() => {
|
|
147
|
+
const timer = setTimeout(() => onAdvance(), 50);
|
|
148
|
+
return () => clearTimeout(timer);
|
|
149
|
+
}, [result.command, onAdvance]);
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className={`yd-command ${className || ""}`}>
|
|
153
|
+
<p>Executing: {result.command}</p>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/* Yarn Dialogue System Styles */
|
|
2
|
+
|
|
3
|
+
/* Main dialogue container */
|
|
4
|
+
.yd-container {
|
|
5
|
+
position: relative;
|
|
6
|
+
width: 100%;
|
|
7
|
+
min-height: 400px;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* Scene container */
|
|
13
|
+
.yd-scene {
|
|
14
|
+
position: absolute;
|
|
15
|
+
top: 0;
|
|
16
|
+
left: 0;
|
|
17
|
+
right: 0;
|
|
18
|
+
bottom: 0;
|
|
19
|
+
width: 100%;
|
|
20
|
+
height: 100%;
|
|
21
|
+
min-height: 400px;
|
|
22
|
+
background-color: rgba(26, 26, 46, 1);
|
|
23
|
+
background-size: cover;
|
|
24
|
+
background-position: center;
|
|
25
|
+
background-repeat: no-repeat;
|
|
26
|
+
transition: opacity 0.6s ease-in-out;
|
|
27
|
+
z-index: 0;
|
|
28
|
+
overflow: visible;
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Scene background transition overlay */
|
|
33
|
+
.yd-scene-next {
|
|
34
|
+
position: absolute;
|
|
35
|
+
top: 0;
|
|
36
|
+
left: 0;
|
|
37
|
+
right: 0;
|
|
38
|
+
bottom: 0;
|
|
39
|
+
background-size: cover;
|
|
40
|
+
background-position: center;
|
|
41
|
+
background-repeat: no-repeat;
|
|
42
|
+
z-index: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Actor image */
|
|
46
|
+
.yd-actor {
|
|
47
|
+
position: absolute;
|
|
48
|
+
top: 0;
|
|
49
|
+
left: 50%;
|
|
50
|
+
transform: translateX(-50%);
|
|
51
|
+
max-height: 70%;
|
|
52
|
+
max-width: 40%;
|
|
53
|
+
object-fit: contain;
|
|
54
|
+
z-index: 1;
|
|
55
|
+
transition: opacity 0.3s ease-in-out;
|
|
56
|
+
opacity: 1;
|
|
57
|
+
pointer-events: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Dialogue box container */
|
|
61
|
+
.yd-dialogue-box {
|
|
62
|
+
position: absolute;
|
|
63
|
+
bottom: 0;
|
|
64
|
+
left: 0;
|
|
65
|
+
right: 0;
|
|
66
|
+
z-index: 10;
|
|
67
|
+
margin: 20px;
|
|
68
|
+
width: calc(100% - 40px);
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.yd-dialogue-box.yd-text-box-end {
|
|
73
|
+
cursor: default;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Text dialogue box content */
|
|
77
|
+
.yd-text-box {
|
|
78
|
+
background-color: rgba(30, 30, 60, 0.9);
|
|
79
|
+
border: 2px solid #4a9eff;
|
|
80
|
+
border-radius: 12px;
|
|
81
|
+
padding: 20px;
|
|
82
|
+
color: #ffffff;
|
|
83
|
+
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
|
84
|
+
min-height: 120px;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Speaker label */
|
|
88
|
+
.yd-speaker {
|
|
89
|
+
background-color: #4a9eff;
|
|
90
|
+
color: #ffffff;
|
|
91
|
+
padding: 8px 16px;
|
|
92
|
+
border-radius: 8px;
|
|
93
|
+
display: inline-block;
|
|
94
|
+
margin-bottom: 12px;
|
|
95
|
+
font-size: 14px;
|
|
96
|
+
font-weight: bold;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Dialogue text */
|
|
100
|
+
.yd-text {
|
|
101
|
+
margin: 0;
|
|
102
|
+
font-size: 18px;
|
|
103
|
+
line-height: 1.6;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.yd-text-with-speaker {
|
|
107
|
+
margin-top: 12px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* Continue indicator */
|
|
111
|
+
.yd-continue {
|
|
112
|
+
text-align: right;
|
|
113
|
+
margin-top: 12px;
|
|
114
|
+
font-size: 24px;
|
|
115
|
+
color: #4a9eff;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* Options container */
|
|
119
|
+
.yd-options-container {
|
|
120
|
+
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
|
121
|
+
position: absolute;
|
|
122
|
+
bottom: 0;
|
|
123
|
+
left: 0;
|
|
124
|
+
right: 0;
|
|
125
|
+
z-index: 10;
|
|
126
|
+
margin: 20px;
|
|
127
|
+
width: calc(100% - 40px);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.yd-options-box {
|
|
131
|
+
background-color: rgba(30, 30, 60, 0.9);
|
|
132
|
+
border: 2px solid #4a9eff;
|
|
133
|
+
border-radius: 12px;
|
|
134
|
+
padding: 20px;
|
|
135
|
+
color: #ffffff;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.yd-options-title {
|
|
139
|
+
margin-bottom: 16px;
|
|
140
|
+
font-size: 16px;
|
|
141
|
+
color: #a0a0ff;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.yd-options-list {
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
gap: 12px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Option button */
|
|
151
|
+
.yd-option-button {
|
|
152
|
+
border: none;
|
|
153
|
+
border-radius: 8px;
|
|
154
|
+
padding: 16px 20px;
|
|
155
|
+
font-size: 16px;
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
text-align: left;
|
|
158
|
+
transition: transform 0.1s, opacity 0.1s;
|
|
159
|
+
font-family: inherit;
|
|
160
|
+
background-color: #4a9eff;
|
|
161
|
+
color: #ffffff;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.yd-option-button:hover {
|
|
165
|
+
transform: scale(1.02);
|
|
166
|
+
opacity: 0.9;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* Command result */
|
|
170
|
+
.yd-command {
|
|
171
|
+
padding: 20px;
|
|
172
|
+
text-align: center;
|
|
173
|
+
color: #888;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Empty/ended dialogue */
|
|
177
|
+
.yd-empty {
|
|
178
|
+
padding: 20px;
|
|
179
|
+
text-align: center;
|
|
180
|
+
}
|
|
181
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { YarnRunner, type IRProgram, type RunnerOptions, type RuntimeResult } from "../runtime/runner.js";
|
|
3
|
+
|
|
4
|
+
export function useYarnRunner(
|
|
5
|
+
program: IRProgram,
|
|
6
|
+
options: RunnerOptions
|
|
7
|
+
): {
|
|
8
|
+
result: RuntimeResult | null;
|
|
9
|
+
advance: (optionIndex?: number) => void;
|
|
10
|
+
runner: YarnRunner;
|
|
11
|
+
} {
|
|
12
|
+
const runnerRef = useRef<YarnRunner | null>(null);
|
|
13
|
+
const [result, setResult] = useState<RuntimeResult | null>(null);
|
|
14
|
+
|
|
15
|
+
// Initialize runner only once
|
|
16
|
+
if (!runnerRef.current) {
|
|
17
|
+
runnerRef.current = new YarnRunner(program, options);
|
|
18
|
+
setResult(runnerRef.current.currentResult);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const runner = runnerRef.current;
|
|
22
|
+
|
|
23
|
+
const advance = useCallback(
|
|
24
|
+
(optionIndex?: number) => {
|
|
25
|
+
runner.advance(optionIndex);
|
|
26
|
+
setResult(runner.currentResult);
|
|
27
|
+
},
|
|
28
|
+
[runner]
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return { result, advance, runner };
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command parser and handler utilities for Yarn Spinner commands.
|
|
3
|
+
* Commands like <<command_name arg1 arg2>> or <<command_name "arg with spaces">>
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExpressionEvaluator as Evaluator } from "./evaluator";
|
|
7
|
+
|
|
8
|
+
export interface ParsedCommand {
|
|
9
|
+
name: string;
|
|
10
|
+
args: string[];
|
|
11
|
+
raw: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a command string like "command_name arg1 arg2" or "set variable value"
|
|
16
|
+
*/
|
|
17
|
+
export function parseCommand(content: string): ParsedCommand {
|
|
18
|
+
const trimmed = content.trim();
|
|
19
|
+
if (!trimmed) {
|
|
20
|
+
throw new Error("Empty command");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parts: string[] = [];
|
|
24
|
+
let current = "";
|
|
25
|
+
let inQuotes = false;
|
|
26
|
+
let quoteChar = "";
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
29
|
+
const char = trimmed[i];
|
|
30
|
+
|
|
31
|
+
if ((char === '"' || char === "'") && !inQuotes) {
|
|
32
|
+
inQuotes = true;
|
|
33
|
+
quoteChar = char;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (char === quoteChar && inQuotes) {
|
|
38
|
+
inQuotes = false;
|
|
39
|
+
quoteChar = "";
|
|
40
|
+
parts.push(current);
|
|
41
|
+
current = "";
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (char === " " && !inQuotes) {
|
|
46
|
+
if (current.trim()) {
|
|
47
|
+
parts.push(current.trim());
|
|
48
|
+
current = "";
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
current += char;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (current.trim()) {
|
|
57
|
+
parts.push(current.trim());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (parts.length === 0) {
|
|
61
|
+
throw new Error("No command name found");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: parts[0],
|
|
66
|
+
args: parts.slice(1),
|
|
67
|
+
raw: content,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Built-in command handlers for common Yarn Spinner commands.
|
|
73
|
+
*/
|
|
74
|
+
export class CommandHandler {
|
|
75
|
+
private handlers = new Map<string, (args: string[], evaluator?: Evaluator) => void | Promise<void>>();
|
|
76
|
+
private variables: Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
constructor(variables: Record<string, unknown> = {}) {
|
|
79
|
+
this.variables = variables;
|
|
80
|
+
this.registerBuiltins();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Register a command handler.
|
|
85
|
+
*/
|
|
86
|
+
register(name: string, handler: (args: string[], evaluator?: Evaluator) => void | Promise<void>): void {
|
|
87
|
+
this.handlers.set(name.toLowerCase(), handler);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Execute a parsed command.
|
|
92
|
+
*/
|
|
93
|
+
async execute(parsed: ParsedCommand, evaluator?: Evaluator): Promise<void> {
|
|
94
|
+
const handler = this.handlers.get(parsed.name.toLowerCase());
|
|
95
|
+
if (handler) {
|
|
96
|
+
await handler(parsed.args, evaluator);
|
|
97
|
+
} else {
|
|
98
|
+
console.warn(`Unknown command: ${parsed.name}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private registerBuiltins(): void {
|
|
103
|
+
// <<set $var to expr>> or <<set $var = expr>> or <<set $var expr>>
|
|
104
|
+
this.register("set", (args, evaluator) => {
|
|
105
|
+
if (!evaluator) return;
|
|
106
|
+
if (args.length < 2) return;
|
|
107
|
+
const varNameRaw = args[0];
|
|
108
|
+
let exprParts = args.slice(1);
|
|
109
|
+
if (exprParts[0] === "to") exprParts = exprParts.slice(1);
|
|
110
|
+
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
|
|
111
|
+
const expr = exprParts.join(" ");
|
|
112
|
+
let value = evaluator.evaluateExpression(expr);
|
|
113
|
+
|
|
114
|
+
// If value is a string starting with ".", try to resolve as enum shorthand
|
|
115
|
+
if (typeof value === "string" && value.startsWith(".")) {
|
|
116
|
+
const enumType = evaluator.getEnumTypeForVariable(varNameRaw);
|
|
117
|
+
if (enumType) {
|
|
118
|
+
value = evaluator.resolveEnumValue(value, enumType);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
|
|
123
|
+
// Setting a variable converts it from smart to regular
|
|
124
|
+
this.variables[key] = value;
|
|
125
|
+
evaluator.setVariable(key, value);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// <<declare $var = expr>>
|
|
129
|
+
this.register("declare", (args, evaluator) => {
|
|
130
|
+
if (!evaluator) return;
|
|
131
|
+
if (args.length < 3) return; // name, '=', expr
|
|
132
|
+
const varNameRaw = args[0];
|
|
133
|
+
let exprParts = args.slice(1);
|
|
134
|
+
if (exprParts[0] === "=") exprParts = exprParts.slice(1);
|
|
135
|
+
const expr = exprParts.join(" ");
|
|
136
|
+
|
|
137
|
+
const key = varNameRaw.startsWith("$") ? varNameRaw.slice(1) : varNameRaw;
|
|
138
|
+
|
|
139
|
+
// Check if expression is "smart" (contains operators, comparisons, or variable references)
|
|
140
|
+
// Smart variables: expressions with operators, comparisons, logical ops, or function calls
|
|
141
|
+
const isSmart = /[+\-*/%<>=!&|]/.test(expr) ||
|
|
142
|
+
/\$\w+/.test(expr) || // references other variables
|
|
143
|
+
/[a-zA-Z_]\w*\s*\(/.test(expr); // function calls
|
|
144
|
+
|
|
145
|
+
if (isSmart) {
|
|
146
|
+
// Store as smart variable - will recalculate on each access
|
|
147
|
+
evaluator.setSmartVariable(key, expr);
|
|
148
|
+
// Also store initial value in variables for immediate use
|
|
149
|
+
const initialValue = evaluator.evaluateExpression(expr);
|
|
150
|
+
this.variables[key] = initialValue;
|
|
151
|
+
} else {
|
|
152
|
+
// Regular variable - evaluate once and store
|
|
153
|
+
let value = evaluator.evaluateExpression(expr);
|
|
154
|
+
|
|
155
|
+
// Check if expr is an enum value (EnumName.CaseName or .CaseName)
|
|
156
|
+
if (typeof value === "string") {
|
|
157
|
+
// Try to extract enum name from EnumName.CaseName
|
|
158
|
+
const enumMatch = expr.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/);
|
|
159
|
+
if (enumMatch) {
|
|
160
|
+
const enumName = enumMatch[1];
|
|
161
|
+
value = evaluator.resolveEnumValue(expr, enumName);
|
|
162
|
+
} else if (value.startsWith(".")) {
|
|
163
|
+
// Shorthand - we can't infer enum type from declaration alone
|
|
164
|
+
// Store as-is, will be resolved on first use if variable has enum type
|
|
165
|
+
value = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.variables[key] = value;
|
|
170
|
+
evaluator.setVariable(key, value);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// <<stop>> - no-op, just a marker
|
|
175
|
+
this.register("stop", () => {
|
|
176
|
+
// Dialogue stop marker
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Forward reference type (avoid circular import)
|
|
182
|
+
type ExpressionEvaluator = import("./evaluator").ExpressionEvaluator;
|
|
183
|
+
|