@flare-engine/dialogue 0.2.0 → 0.3.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/dist/index.js CHANGED
@@ -1,257 +1,268 @@
1
- "use strict";
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __hasOwnProp = Object.prototype.hasOwnProperty;
6
- var __export = (target, all) => {
7
- for (var name in all)
8
- __defProp(target, name, { get: all[name], enumerable: true });
9
- };
10
- var __copyProps = (to, from, except, desc) => {
11
- if (from && typeof from === "object" || typeof from === "function") {
12
- for (let key of __getOwnPropNames(from))
13
- if (!__hasOwnProp.call(to, key) && key !== except)
14
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
- }
16
- return to;
17
- };
18
- var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
-
20
- // src/index.ts
21
- var index_exports = {};
22
- __export(index_exports, {
23
- completeTextReveal: () => completeTextReveal,
24
- createDialogueGraph: () => createDialogueGraph,
25
- createDialogueRunner: () => createDialogueRunner,
26
- createTextReveal: () => createTextReveal,
27
- getNode: () => getNode,
28
- interpolate: () => interpolate,
29
- resetTextReveal: () => resetTextReveal,
30
- updateTextReveal: () => updateTextReveal
31
- });
32
- module.exports = __toCommonJS(index_exports);
33
-
34
- // src/graph.ts
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _flare_engine_events = require("@flare-engine/events");
3
+ //#region src/graph.ts
35
4
  function createDialogueGraph(id, start, nodes) {
36
- return { id, start, nodes };
5
+ return {
6
+ id,
7
+ start,
8
+ nodes
9
+ };
37
10
  }
38
11
  function getNode(graph, nodeId) {
39
- return graph.nodes[nodeId];
12
+ return Object.hasOwn(graph.nodes, nodeId) ? graph.nodes[nodeId] : void 0;
40
13
  }
41
-
42
- // src/runner.ts
43
- var import_events = require("@flare-engine/events");
14
+ //#endregion
15
+ //#region src/runner.ts
16
+ /**
17
+ * Dialogue runner — walks a DialogueGraph, evaluating conditions,
18
+ * interpolating variables, and emitting signals for each step.
19
+ */
20
+ var DialogueError = class extends Error {
21
+ code;
22
+ /** Source node whose transition triggered the error (or the entry id when there is no source). */
23
+ nodeId;
24
+ /** 'missing-node': the target id that could not be resolved. */
25
+ missingId;
26
+ /** 'cycle': the node-id chain walked before the step budget was exceeded. */
27
+ chain;
28
+ constructor(code, message, detail) {
29
+ super(message);
30
+ this.name = "DialogueError";
31
+ this.code = code;
32
+ this.nodeId = detail.nodeId;
33
+ this.missingId = detail.missingId;
34
+ this.chain = detail.chain;
35
+ }
36
+ };
44
37
  function interpolate(template, vars) {
45
- return template.replace(/\{(\w+)\}/g, (_match, key) => {
46
- const val = vars[key];
47
- return val !== void 0 ? String(val) : `{${key}}`;
48
- });
38
+ return template.replace(/\{(\w+)\}/g, (_match, key) => {
39
+ const val = Object.hasOwn(vars, key) ? vars[key] : void 0;
40
+ return val !== void 0 ? String(val) : `{${key}}`;
41
+ });
49
42
  }
43
+ const STEP_BUDGET = 1e3;
50
44
  function createDialogueRunner(options) {
51
- const onLine = new import_events.Signal();
52
- const onChoice = new import_events.Signal();
53
- const onAction = new import_events.Signal();
54
- const onEnd = new import_events.Signal();
55
- const conditions = options?.conditions ?? (() => true);
56
- const actions = options?.actions;
57
- let _state = "idle";
58
- let _graph = null;
59
- let _currentNodeId = null;
60
- let _vars = {};
61
- let _activeOptions = [];
62
- function setState(s) {
63
- _state = s;
64
- }
65
- function processNode(nodeId) {
66
- if (!_graph) return;
67
- const node = getNode(_graph, nodeId);
68
- if (!node) {
69
- end();
70
- return;
71
- }
72
- _currentNodeId = nodeId;
73
- switch (node.kind) {
74
- case "line":
75
- processLine(node, nodeId);
76
- break;
77
- case "choice":
78
- processChoice(node, nodeId);
79
- break;
80
- case "branch":
81
- processBranch(node);
82
- break;
83
- case "action":
84
- processAction(node);
85
- break;
86
- case "end":
87
- end();
88
- break;
89
- }
90
- }
91
- function processLine(node, nodeId) {
92
- setState("running");
93
- const text = interpolate(node.text, _vars);
94
- onLine.emit({
95
- speaker: node.speaker,
96
- speakerPortraitKey: node.speakerPortraitKey,
97
- text,
98
- nodeId
99
- });
100
- }
101
- function processChoice(node, nodeId) {
102
- _activeOptions = [];
103
- for (let i = 0; i < node.options.length; i++) {
104
- const opt = node.options[i];
105
- if (opt.condition && !conditions(opt.condition)) continue;
106
- _activeOptions.push({
107
- index: _activeOptions.length,
108
- text: interpolate(opt.text, _vars),
109
- next: opt.next
110
- });
111
- }
112
- if (_activeOptions.length === 0) {
113
- end();
114
- return;
115
- }
116
- setState("waiting-choice");
117
- onChoice.emit({
118
- prompt: node.prompt ? interpolate(node.prompt, _vars) : void 0,
119
- speaker: node.speaker,
120
- options: _activeOptions,
121
- nodeId
122
- });
123
- }
124
- function processBranch(node) {
125
- for (let i = 0; i < node.arms.length; i++) {
126
- const arm = node.arms[i];
127
- if (conditions(arm.condition)) {
128
- processNode(arm.next);
129
- return;
130
- }
131
- }
132
- if (node.fallback) {
133
- processNode(node.fallback);
134
- } else {
135
- end();
136
- }
137
- }
138
- function processAction(node) {
139
- onAction.emit(node.action);
140
- actions?.(node.action);
141
- if (node.next) {
142
- processNode(node.next);
143
- } else {
144
- end();
145
- }
146
- }
147
- function end() {
148
- setState("ended");
149
- const graphId = _graph?.id ?? "";
150
- _currentNodeId = null;
151
- onEnd.emit(graphId);
152
- }
153
- const runner = {
154
- onLine,
155
- onChoice,
156
- onAction,
157
- onEnd,
158
- get state() {
159
- return _state;
160
- },
161
- get currentNodeId() {
162
- return _currentNodeId;
163
- },
164
- get graphId() {
165
- return _graph?.id ?? null;
166
- },
167
- start(graph, variables) {
168
- _graph = graph;
169
- _vars = variables ?? {};
170
- _activeOptions = [];
171
- setState("running");
172
- processNode(graph.start);
173
- },
174
- advance() {
175
- if (_state !== "running" || !_graph || !_currentNodeId) return;
176
- const node = getNode(_graph, _currentNodeId);
177
- if (!node || node.kind !== "line") return;
178
- if (node.next) {
179
- processNode(node.next);
180
- } else {
181
- end();
182
- }
183
- },
184
- selectChoice(index) {
185
- if (_state !== "waiting-choice") return;
186
- const opt = _activeOptions[index];
187
- if (!opt) return;
188
- _activeOptions = [];
189
- setState("running");
190
- processNode(opt.next);
191
- },
192
- stop() {
193
- setState("idle");
194
- _graph = null;
195
- _currentNodeId = null;
196
- _vars = {};
197
- _activeOptions = [];
198
- }
199
- };
200
- return runner;
45
+ const onLine = new _flare_engine_events.Signal();
46
+ const onChoice = new _flare_engine_events.Signal();
47
+ const onAction = new _flare_engine_events.Signal();
48
+ const onEnd = new _flare_engine_events.Signal();
49
+ const conditions = options?.conditions ?? (() => true);
50
+ const actions = options?.actions;
51
+ const onError = options?.onError;
52
+ let _state = "idle";
53
+ let _graph = null;
54
+ let _currentNodeId = null;
55
+ let _vars = {};
56
+ let _activeOptions = [];
57
+ function setState(s) {
58
+ _state = s;
59
+ }
60
+ function end() {
61
+ setState("ended");
62
+ const graphId = _graph?.id ?? "";
63
+ _currentNodeId = null;
64
+ onEnd.emit(graphId);
65
+ }
66
+ function reportMissing(fromNodeId, missingId) {
67
+ const err = new DialogueError("missing-node", `Dialogue '${_graph?.id ?? ""}' references missing node '${missingId}'${fromNodeId ? ` from node '${fromNodeId}'` : " as its start node"} — ending conversation.`, {
68
+ nodeId: fromNodeId ?? missingId,
69
+ missingId
70
+ });
71
+ if (onError) onError(err);
72
+ else console.warn(err.message);
73
+ }
74
+ function processLine(node, nodeId) {
75
+ setState("running");
76
+ const text = interpolate(node.text, _vars);
77
+ onLine.emit({
78
+ speaker: node.speaker,
79
+ speakerPortraitKey: node.speakerPortraitKey,
80
+ text,
81
+ nodeId
82
+ });
83
+ }
84
+ function processChoice(node, nodeId) {
85
+ _activeOptions = [];
86
+ for (let i = 0; i < node.options.length; i++) {
87
+ const opt = node.options[i];
88
+ if (opt.condition && !conditions(opt.condition)) continue;
89
+ _activeOptions.push({
90
+ index: _activeOptions.length,
91
+ text: interpolate(opt.text, _vars),
92
+ next: opt.next
93
+ });
94
+ }
95
+ if (_activeOptions.length === 0) {
96
+ end();
97
+ return;
98
+ }
99
+ setState("waiting-choice");
100
+ onChoice.emit({
101
+ prompt: node.prompt ? interpolate(node.prompt, _vars) : void 0,
102
+ speaker: node.speaker,
103
+ options: _activeOptions,
104
+ nodeId
105
+ });
106
+ }
107
+ function run(entryId, fromNodeId) {
108
+ if (!_graph) return;
109
+ let currentId = entryId;
110
+ let from = fromNodeId;
111
+ const chain = [];
112
+ let steps = 0;
113
+ while (currentId !== null) {
114
+ const nodeId = currentId;
115
+ const node = getNode(_graph, nodeId);
116
+ if (!node) {
117
+ reportMissing(from, nodeId);
118
+ end();
119
+ return;
120
+ }
121
+ chain.push(nodeId);
122
+ if (++steps > STEP_BUDGET) throw new DialogueError("cycle", `Dialogue '${_graph.id}' cycle detected — step budget exceeded (chain: …${chain.slice(-12).join("→")})`, {
123
+ nodeId,
124
+ chain: chain.slice()
125
+ });
126
+ _currentNodeId = nodeId;
127
+ from = nodeId;
128
+ switch (node.kind) {
129
+ case "line":
130
+ processLine(node, nodeId);
131
+ return;
132
+ case "choice":
133
+ processChoice(node, nodeId);
134
+ return;
135
+ case "branch": {
136
+ let next = node.fallback;
137
+ for (const arm of node.arms) if (conditions(arm.condition)) {
138
+ next = arm.next;
139
+ break;
140
+ }
141
+ currentId = next;
142
+ break;
143
+ }
144
+ case "action":
145
+ onAction.emit(node.action);
146
+ actions?.(node.action);
147
+ currentId = node.next;
148
+ break;
149
+ case "end":
150
+ end();
151
+ return;
152
+ }
153
+ }
154
+ end();
155
+ }
156
+ return {
157
+ onLine,
158
+ onChoice,
159
+ onAction,
160
+ onEnd,
161
+ get state() {
162
+ return _state;
163
+ },
164
+ get currentNodeId() {
165
+ return _currentNodeId;
166
+ },
167
+ get graphId() {
168
+ return _graph?.id ?? null;
169
+ },
170
+ start(graph, variables) {
171
+ _graph = graph;
172
+ _vars = variables ?? {};
173
+ _activeOptions = [];
174
+ setState("running");
175
+ run(graph.start, null);
176
+ },
177
+ advance() {
178
+ if (_state !== "running" || !_graph || !_currentNodeId) return;
179
+ const node = getNode(_graph, _currentNodeId);
180
+ if (node?.kind !== "line") return;
181
+ if (node.next) run(node.next, _currentNodeId);
182
+ else end();
183
+ },
184
+ selectChoice(index) {
185
+ if (_state !== "waiting-choice") return;
186
+ const opt = _activeOptions[index];
187
+ if (!opt) return;
188
+ _activeOptions = [];
189
+ setState("running");
190
+ run(opt.next, _currentNodeId);
191
+ },
192
+ stop() {
193
+ setState("idle");
194
+ _graph = null;
195
+ _currentNodeId = null;
196
+ _vars = {};
197
+ _activeOptions = [];
198
+ }
199
+ };
201
200
  }
202
-
203
- // src/text-reveal.ts
204
- var DEFAULT_SPEED = 30;
205
- var DEFAULT_PUNCT_DELAY = 0.15;
206
- var PUNCTUATION = /* @__PURE__ */ new Set([".", "!", "?", ":", ";"]);
201
+ //#endregion
202
+ //#region src/text-reveal.ts
203
+ const DEFAULT_SPEED = 30;
204
+ const DEFAULT_PUNCT_DELAY = .15;
205
+ const PUNCTUATION = /* @__PURE__ */ new Set([
206
+ ".",
207
+ "!",
208
+ "?",
209
+ ":",
210
+ ";"
211
+ ]);
207
212
  function createTextReveal(text) {
208
- if (text.length === 0) {
209
- return { fullText: text, visibleText: text, complete: true, _cursor: 0 };
210
- }
211
- return { fullText: text, visibleText: "", complete: false, _cursor: 0 };
213
+ if (text.length === 0) return {
214
+ fullText: text,
215
+ visibleText: text,
216
+ complete: true,
217
+ _cursor: 0
218
+ };
219
+ return {
220
+ fullText: text,
221
+ visibleText: "",
222
+ complete: false,
223
+ _cursor: 0
224
+ };
212
225
  }
213
226
  function updateTextReveal(state, dt, options) {
214
- if (state.complete) return;
215
- const speed = options?.speed ?? DEFAULT_SPEED;
216
- const punctDelay = options?.punctuationDelay ?? DEFAULT_PUNCT_DELAY;
217
- const len = state.fullText.length;
218
- const prevCharIdx = Math.floor(state._cursor) - 1;
219
- let effectiveSpeed = speed;
220
- if (prevCharIdx >= 0 && prevCharIdx < len) {
221
- const prevChar = state.fullText[prevCharIdx];
222
- if (PUNCTUATION.has(prevChar)) {
223
- effectiveSpeed = punctDelay > 0 ? 1 / punctDelay : speed;
224
- }
225
- }
226
- state._cursor += effectiveSpeed * dt;
227
- const charCount = Math.min(Math.floor(state._cursor), len);
228
- state.visibleText = state.fullText.slice(0, charCount);
229
- if (charCount >= len) {
230
- state.visibleText = state.fullText;
231
- state.complete = true;
232
- }
227
+ if (state.complete) return;
228
+ const speed = options?.speed ?? DEFAULT_SPEED;
229
+ const punctDelay = options?.punctuationDelay ?? DEFAULT_PUNCT_DELAY;
230
+ const len = state.fullText.length;
231
+ const prevCharIdx = Math.floor(state._cursor) - 1;
232
+ let effectiveSpeed = speed;
233
+ if (prevCharIdx >= 0 && prevCharIdx < len) {
234
+ const prevChar = state.fullText[prevCharIdx];
235
+ if (PUNCTUATION.has(prevChar)) effectiveSpeed = punctDelay > 0 ? 1 / punctDelay : speed;
236
+ }
237
+ state._cursor += effectiveSpeed * dt;
238
+ const charCount = Math.min(Math.floor(state._cursor), len);
239
+ state.visibleText = state.fullText.slice(0, charCount);
240
+ if (charCount >= len) {
241
+ state.visibleText = state.fullText;
242
+ state.complete = true;
243
+ }
233
244
  }
234
245
  function completeTextReveal(state) {
235
- state.visibleText = state.fullText;
236
- state._cursor = state.fullText.length;
237
- state.complete = true;
246
+ state.visibleText = state.fullText;
247
+ state._cursor = state.fullText.length;
248
+ state.complete = true;
238
249
  }
239
250
  function resetTextReveal(state, newText) {
240
- const text = newText ?? state.fullText;
241
- state.fullText = text;
242
- state.visibleText = "";
243
- state._cursor = 0;
244
- state.complete = text.length === 0;
251
+ const text = newText ?? state.fullText;
252
+ state.fullText = text;
253
+ state.visibleText = "";
254
+ state._cursor = 0;
255
+ state.complete = text.length === 0;
245
256
  }
246
- // Annotate the CommonJS export names for ESM import in node:
247
- 0 && (module.exports = {
248
- completeTextReveal,
249
- createDialogueGraph,
250
- createDialogueRunner,
251
- createTextReveal,
252
- getNode,
253
- interpolate,
254
- resetTextReveal,
255
- updateTextReveal
256
- });
257
+ //#endregion
258
+ exports.DialogueError = DialogueError;
259
+ exports.completeTextReveal = completeTextReveal;
260
+ exports.createDialogueGraph = createDialogueGraph;
261
+ exports.createDialogueRunner = createDialogueRunner;
262
+ exports.createTextReveal = createTextReveal;
263
+ exports.getNode = getNode;
264
+ exports.interpolate = interpolate;
265
+ exports.resetTextReveal = resetTextReveal;
266
+ exports.updateTextReveal = updateTextReveal;
267
+
257
268
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/graph.ts","../src/runner.ts","../src/text-reveal.ts"],"sourcesContent":["export {\n type LineNode,\n type ChoiceOption,\n type ChoiceNode,\n type BranchArm,\n type BranchNode,\n type ActionNode,\n type EndNode,\n type DialogueNode,\n type DialogueGraph,\n createDialogueGraph,\n getNode,\n} from './graph.js';\n\nexport {\n type ConditionResolver,\n type ActionHandler,\n type VariableBag,\n type DialogueLineEvent,\n type DialogueChoiceEvent,\n type ActiveOption,\n type DialogueRunnerState,\n type DialogueRunner,\n type DialogueRunnerOptions,\n createDialogueRunner,\n interpolate,\n} from './runner.js';\n\nexport {\n type TextRevealState,\n type TextRevealOptions,\n createTextReveal,\n updateTextReveal,\n completeTextReveal,\n resetTextReveal,\n} from './text-reveal.js';\n","/**\n * Dialogue graph data types.\n *\n * A dialogue graph is a directed graph of nodes. Each node is one of:\n * - `line`: NPC/character speaks. Optional `next` pointer.\n * - `choice`: Player picks from options. Each option points to a node.\n * - `branch`: Condition-based routing. First truthy condition wins.\n * - `action`: Fires a callback, then advances to `next`.\n * - `end`: Terminal node. Conversation stops.\n *\n * Graphs are plain JSON — no classes, no runtime coupling.\n */\n\n// ─── Node types ──────────────────────────────────────────────────\n\nexport interface LineNode {\n readonly kind: 'line';\n /** Speaker name / id. */\n readonly speaker: string;\n /**\n * Optional portrait key resolved by the UI layer to an avatar image /\n * shape / atlas frame. The dialogue runtime is portrait-agnostic — it\n * forwards the key via `DialogueLineEvent.speakerPortraitKey`.\n */\n readonly speakerPortraitKey?: string;\n /** Text template. Supports `{var}` interpolation via the runner's variable bag. */\n readonly text: string;\n /** Next node id, or null to end. */\n readonly next: string | null;\n}\n\nexport interface ChoiceOption {\n /** Display text. Supports `{var}` interpolation. */\n readonly text: string;\n /** Node id to jump to when selected. */\n readonly next: string;\n /** Optional condition key — option is hidden when condition returns false. */\n readonly condition?: string;\n}\n\nexport interface ChoiceNode {\n readonly kind: 'choice';\n /** Optional prompt text before the choices. */\n readonly prompt?: string;\n /** Speaker for the prompt, if any. */\n readonly speaker?: string;\n readonly options: readonly ChoiceOption[];\n}\n\nexport interface BranchArm {\n /** Condition key evaluated by the runner's condition resolver. */\n readonly condition: string;\n /** Node id to jump to when condition is true. */\n readonly next: string;\n}\n\nexport interface BranchNode {\n readonly kind: 'branch';\n readonly arms: readonly BranchArm[];\n /** Fallback node id if no arm matches. Null → end. */\n readonly fallback: string | null;\n}\n\nexport interface ActionNode {\n readonly kind: 'action';\n /** Callback key. Runner looks this up in the action registry. */\n readonly action: string;\n /** Next node id after the action fires. */\n readonly next: string | null;\n}\n\nexport interface EndNode {\n readonly kind: 'end';\n}\n\nexport type DialogueNode = LineNode | ChoiceNode | BranchNode | ActionNode | EndNode;\n\n// ─── Graph ───────────────────────────────────────────────────────\n\nexport interface DialogueGraph {\n /** Unique graph identifier. */\n readonly id: string;\n /** Node id to start from. */\n readonly start: string;\n /** All nodes keyed by id. */\n readonly nodes: Record<string, DialogueNode>;\n}\n\nexport function createDialogueGraph(\n id: string,\n start: string,\n nodes: Record<string, DialogueNode>,\n): DialogueGraph {\n return { id, start, nodes };\n}\n\nexport function getNode(graph: DialogueGraph, nodeId: string): DialogueNode | undefined {\n return graph.nodes[nodeId];\n}\n","/**\n * Dialogue runner — walks a DialogueGraph, evaluating conditions,\n * interpolating variables, and emitting signals for each step.\n */\n\nimport { Signal } from '@flare-engine/events';\nimport type { ChoiceNode, DialogueGraph, LineNode } from './graph.js';\nimport { getNode } from './graph.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport type ConditionResolver = (key: string) => boolean;\nexport type ActionHandler = (key: string) => void | Promise<void>;\nexport type VariableBag = Record<string, string | number>;\n\nexport interface DialogueLineEvent {\n readonly speaker: string;\n /** Optional portrait key forwarded verbatim from the source LineNode. */\n readonly speakerPortraitKey?: string;\n readonly text: string;\n readonly nodeId: string;\n}\n\nexport interface DialogueChoiceEvent {\n readonly prompt: string | undefined;\n readonly speaker: string | undefined;\n readonly options: readonly ActiveOption[];\n readonly nodeId: string;\n}\n\nexport interface ActiveOption {\n readonly index: number;\n readonly text: string;\n readonly next: string;\n}\n\nexport type DialogueRunnerState = 'idle' | 'running' | 'waiting-choice' | 'ended';\n\n// ─── Interpolation ───────────────────────────────────────────────\n\nexport function interpolate(template: string, vars: VariableBag): string {\n return template.replace(/\\{(\\w+)\\}/g, (_match, key: string) => {\n const val = vars[key];\n return val !== undefined ? String(val) : `{${key}}`;\n });\n}\n\n// ─── Runner ──────────────────────────────────────────────────────\n\nexport interface DialogueRunner {\n readonly onLine: Signal<DialogueLineEvent>;\n readonly onChoice: Signal<DialogueChoiceEvent>;\n readonly onAction: Signal<string>;\n readonly onEnd: Signal<string>;\n\n readonly state: DialogueRunnerState;\n readonly currentNodeId: string | null;\n readonly graphId: string | null;\n\n start(graph: DialogueGraph, variables?: VariableBag): void;\n advance(): void;\n selectChoice(index: number): void;\n stop(): void;\n}\n\nexport interface DialogueRunnerOptions {\n conditions?: ConditionResolver;\n actions?: ActionHandler;\n}\n\nexport function createDialogueRunner(options?: DialogueRunnerOptions): DialogueRunner {\n const onLine = new Signal<DialogueLineEvent>();\n const onChoice = new Signal<DialogueChoiceEvent>();\n const onAction = new Signal<string>();\n const onEnd = new Signal<string>();\n\n const conditions = options?.conditions ?? (() => true);\n const actions = options?.actions;\n\n let _state: DialogueRunnerState = 'idle';\n let _graph: DialogueGraph | null = null;\n let _currentNodeId: string | null = null;\n let _vars: VariableBag = {};\n let _activeOptions: ActiveOption[] = [];\n\n function setState(s: DialogueRunnerState): void {\n _state = s;\n }\n\n function processNode(nodeId: string): void {\n if (!_graph) return;\n const node = getNode(_graph, nodeId);\n if (!node) {\n end();\n return;\n }\n _currentNodeId = nodeId;\n\n switch (node.kind) {\n case 'line':\n processLine(node, nodeId);\n break;\n case 'choice':\n processChoice(node, nodeId);\n break;\n case 'branch':\n processBranch(node);\n break;\n case 'action':\n processAction(node);\n break;\n case 'end':\n end();\n break;\n }\n }\n\n function processLine(node: LineNode, nodeId: string): void {\n setState('running');\n const text = interpolate(node.text, _vars);\n onLine.emit({\n speaker: node.speaker,\n speakerPortraitKey: node.speakerPortraitKey,\n text,\n nodeId,\n });\n }\n\n function processChoice(node: ChoiceNode, nodeId: string): void {\n // Filter options by condition\n _activeOptions = [];\n for (let i = 0; i < node.options.length; i++) {\n const opt = node.options[i]!;\n if (opt.condition && !conditions(opt.condition)) continue;\n _activeOptions.push({\n index: _activeOptions.length,\n text: interpolate(opt.text, _vars),\n next: opt.next,\n });\n }\n\n if (_activeOptions.length === 0) {\n // No valid options — treat as end\n end();\n return;\n }\n\n setState('waiting-choice');\n onChoice.emit({\n prompt: node.prompt ? interpolate(node.prompt, _vars) : undefined,\n speaker: node.speaker,\n options: _activeOptions,\n nodeId,\n });\n }\n\n function processBranch(node: {\n arms: readonly { condition: string; next: string }[];\n fallback: string | null;\n }): void {\n for (let i = 0; i < node.arms.length; i++) {\n const arm = node.arms[i]!;\n if (conditions(arm.condition)) {\n processNode(arm.next);\n return;\n }\n }\n if (node.fallback) {\n processNode(node.fallback);\n } else {\n end();\n }\n }\n\n function processAction(node: { action: string; next: string | null }): void {\n onAction.emit(node.action);\n actions?.(node.action);\n if (node.next) {\n processNode(node.next);\n } else {\n end();\n }\n }\n\n function end(): void {\n setState('ended');\n const graphId = _graph?.id ?? '';\n _currentNodeId = null;\n onEnd.emit(graphId);\n }\n\n const runner: DialogueRunner = {\n onLine,\n onChoice,\n onAction,\n onEnd,\n\n get state() {\n return _state;\n },\n get currentNodeId() {\n return _currentNodeId;\n },\n get graphId() {\n return _graph?.id ?? null;\n },\n\n start(graph: DialogueGraph, variables?: VariableBag): void {\n _graph = graph;\n _vars = variables ?? {};\n _activeOptions = [];\n setState('running');\n processNode(graph.start);\n },\n\n advance(): void {\n if (_state !== 'running' || !_graph || !_currentNodeId) return;\n const node = getNode(_graph, _currentNodeId);\n if (!node || node.kind !== 'line') return;\n if (node.next) {\n processNode(node.next);\n } else {\n end();\n }\n },\n\n selectChoice(index: number): void {\n if (_state !== 'waiting-choice') return;\n const opt = _activeOptions[index];\n if (!opt) return;\n _activeOptions = [];\n setState('running');\n processNode(opt.next);\n },\n\n stop(): void {\n setState('idle');\n _graph = null;\n _currentNodeId = null;\n _vars = {};\n _activeOptions = [];\n },\n };\n\n return runner;\n}\n","/**\n * Text-over-time reveal hook for dialogue lines.\n *\n * Drives a typewriter effect: call `updateTextReveal(state, dt)` each frame\n * and read `state.visibleText` for the currently revealed substring.\n * Supports per-character speed, instant-complete, and pause-on-punctuation.\n */\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TextRevealState {\n /** Full text being revealed. */\n readonly fullText: string;\n /** Characters revealed so far. */\n visibleText: string;\n /** Whether the full text has been revealed. */\n complete: boolean;\n /** Internal fractional character cursor. */\n _cursor: number;\n}\n\nexport interface TextRevealOptions {\n /** Characters per second. Default: 30. */\n speed?: number;\n /** Extra delay (in seconds) after punctuation (.!?). Default: 0.15. */\n punctuationDelay?: number;\n}\n\nconst DEFAULT_SPEED = 30;\nconst DEFAULT_PUNCT_DELAY = 0.15;\n\nconst PUNCTUATION = new Set(['.', '!', '?', ':', ';']);\n\n// ─── API ─────────────────────────────────────────────────────────\n\nexport function createTextReveal(text: string): TextRevealState {\n if (text.length === 0) {\n return { fullText: text, visibleText: text, complete: true, _cursor: 0 };\n }\n return { fullText: text, visibleText: '', complete: false, _cursor: 0 };\n}\n\nexport function updateTextReveal(\n state: TextRevealState,\n dt: number,\n options?: TextRevealOptions,\n): void {\n if (state.complete) return;\n\n const speed = options?.speed ?? DEFAULT_SPEED;\n const punctDelay = options?.punctuationDelay ?? DEFAULT_PUNCT_DELAY;\n const len = state.fullText.length;\n\n // Determine effective speed — slow down after punctuation\n const prevCharIdx = Math.floor(state._cursor) - 1;\n let effectiveSpeed = speed;\n if (prevCharIdx >= 0 && prevCharIdx < len) {\n const prevChar = state.fullText[prevCharIdx]!;\n if (PUNCTUATION.has(prevChar)) {\n // Slow down: treat the punctuation gap as taking `punctDelay` seconds\n // Effective speed = 1 / punctDelay so cursor advances ~1 char in punctDelay time\n effectiveSpeed = punctDelay > 0 ? 1 / punctDelay : speed;\n }\n }\n\n state._cursor += effectiveSpeed * dt;\n const charCount = Math.min(Math.floor(state._cursor), len);\n state.visibleText = state.fullText.slice(0, charCount);\n\n if (charCount >= len) {\n state.visibleText = state.fullText;\n state.complete = true;\n }\n}\n\nexport function completeTextReveal(state: TextRevealState): void {\n state.visibleText = state.fullText;\n state._cursor = state.fullText.length;\n state.complete = true;\n}\n\nexport function resetTextReveal(state: TextRevealState, newText?: string): void {\n const text = newText ?? state.fullText;\n (state as { fullText: string }).fullText = text;\n state.visibleText = '';\n state._cursor = 0;\n state.complete = text.length === 0;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACwFO,SAAS,oBACd,IACA,OACA,OACe;AACf,SAAO,EAAE,IAAI,OAAO,MAAM;AAC5B;AAEO,SAAS,QAAQ,OAAsB,QAA0C;AACtF,SAAO,MAAM,MAAM,MAAM;AAC3B;;;AC7FA,oBAAuB;AAmChB,SAAS,YAAY,UAAkB,MAA2B;AACvE,SAAO,SAAS,QAAQ,cAAc,CAAC,QAAQ,QAAgB;AAC7D,UAAM,MAAM,KAAK,GAAG;AACpB,WAAO,QAAQ,SAAY,OAAO,GAAG,IAAI,IAAI,GAAG;AAAA,EAClD,CAAC;AACH;AAyBO,SAAS,qBAAqB,SAAiD;AACpF,QAAM,SAAS,IAAI,qBAA0B;AAC7C,QAAM,WAAW,IAAI,qBAA4B;AACjD,QAAM,WAAW,IAAI,qBAAe;AACpC,QAAM,QAAQ,IAAI,qBAAe;AAEjC,QAAM,aAAa,SAAS,eAAe,MAAM;AACjD,QAAM,UAAU,SAAS;AAEzB,MAAI,SAA8B;AAClC,MAAI,SAA+B;AACnC,MAAI,iBAAgC;AACpC,MAAI,QAAqB,CAAC;AAC1B,MAAI,iBAAiC,CAAC;AAEtC,WAAS,SAAS,GAA8B;AAC9C,aAAS;AAAA,EACX;AAEA,WAAS,YAAY,QAAsB;AACzC,QAAI,CAAC,OAAQ;AACb,UAAM,OAAO,QAAQ,QAAQ,MAAM;AACnC,QAAI,CAAC,MAAM;AACT,UAAI;AACJ;AAAA,IACF;AACA,qBAAiB;AAEjB,YAAQ,KAAK,MAAM;AAAA,MACjB,KAAK;AACH,oBAAY,MAAM,MAAM;AACxB;AAAA,MACF,KAAK;AACH,sBAAc,MAAM,MAAM;AAC1B;AAAA,MACF,KAAK;AACH,sBAAc,IAAI;AAClB;AAAA,MACF,KAAK;AACH,sBAAc,IAAI;AAClB;AAAA,MACF,KAAK;AACH,YAAI;AACJ;AAAA,IACJ;AAAA,EACF;AAEA,WAAS,YAAY,MAAgB,QAAsB;AACzD,aAAS,SAAS;AAClB,UAAM,OAAO,YAAY,KAAK,MAAM,KAAK;AACzC,WAAO,KAAK;AAAA,MACV,SAAS,KAAK;AAAA,MACd,oBAAoB,KAAK;AAAA,MACzB;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH;AAEA,WAAS,cAAc,MAAkB,QAAsB;AAE7D,qBAAiB,CAAC;AAClB,aAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,QAAQ,KAAK;AAC5C,YAAM,MAAM,KAAK,QAAQ,CAAC;AAC1B,UAAI,IAAI,aAAa,CAAC,WAAW,IAAI,SAAS,EAAG;AACjD,qBAAe,KAAK;AAAA,QAClB,OAAO,eAAe;AAAA,QACtB,MAAM,YAAY,IAAI,MAAM,KAAK;AAAA,QACjC,MAAM,IAAI;AAAA,MACZ,CAAC;AAAA,IACH;AAEA,QAAI,eAAe,WAAW,GAAG;AAE/B,UAAI;AACJ;AAAA,IACF;AAEA,aAAS,gBAAgB;AACzB,aAAS,KAAK;AAAA,MACZ,QAAQ,KAAK,SAAS,YAAY,KAAK,QAAQ,KAAK,IAAI;AAAA,MACxD,SAAS,KAAK;AAAA,MACd,SAAS;AAAA,MACT;AAAA,IACF,CAAC;AAAA,EACH;AAEA,WAAS,cAAc,MAGd;AACP,aAAS,IAAI,GAAG,IAAI,KAAK,KAAK,QAAQ,KAAK;AACzC,YAAM,MAAM,KAAK,KAAK,CAAC;AACvB,UAAI,WAAW,IAAI,SAAS,GAAG;AAC7B,oBAAY,IAAI,IAAI;AACpB;AAAA,MACF;AAAA,IACF;AACA,QAAI,KAAK,UAAU;AACjB,kBAAY,KAAK,QAAQ;AAAA,IAC3B,OAAO;AACL,UAAI;AAAA,IACN;AAAA,EACF;AAEA,WAAS,cAAc,MAAqD;AAC1E,aAAS,KAAK,KAAK,MAAM;AACzB,cAAU,KAAK,MAAM;AACrB,QAAI,KAAK,MAAM;AACb,kBAAY,KAAK,IAAI;AAAA,IACvB,OAAO;AACL,UAAI;AAAA,IACN;AAAA,EACF;AAEA,WAAS,MAAY;AACnB,aAAS,OAAO;AAChB,UAAM,UAAU,QAAQ,MAAM;AAC9B,qBAAiB;AACjB,UAAM,KAAK,OAAO;AAAA,EACpB;AAEA,QAAM,SAAyB;AAAA,IAC7B;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IAEA,IAAI,QAAQ;AACV,aAAO;AAAA,IACT;AAAA,IACA,IAAI,gBAAgB;AAClB,aAAO;AAAA,IACT;AAAA,IACA,IAAI,UAAU;AACZ,aAAO,QAAQ,MAAM;AAAA,IACvB;AAAA,IAEA,MAAM,OAAsB,WAA+B;AACzD,eAAS;AACT,cAAQ,aAAa,CAAC;AACtB,uBAAiB,CAAC;AAClB,eAAS,SAAS;AAClB,kBAAY,MAAM,KAAK;AAAA,IACzB;AAAA,IAEA,UAAgB;AACd,UAAI,WAAW,aAAa,CAAC,UAAU,CAAC,eAAgB;AACxD,YAAM,OAAO,QAAQ,QAAQ,cAAc;AAC3C,UAAI,CAAC,QAAQ,KAAK,SAAS,OAAQ;AACnC,UAAI,KAAK,MAAM;AACb,oBAAY,KAAK,IAAI;AAAA,MACvB,OAAO;AACL,YAAI;AAAA,MACN;AAAA,IACF;AAAA,IAEA,aAAa,OAAqB;AAChC,UAAI,WAAW,iBAAkB;AACjC,YAAM,MAAM,eAAe,KAAK;AAChC,UAAI,CAAC,IAAK;AACV,uBAAiB,CAAC;AAClB,eAAS,SAAS;AAClB,kBAAY,IAAI,IAAI;AAAA,IACtB;AAAA,IAEA,OAAa;AACX,eAAS,MAAM;AACf,eAAS;AACT,uBAAiB;AACjB,cAAQ,CAAC;AACT,uBAAiB,CAAC;AAAA,IACpB;AAAA,EACF;AAEA,SAAO;AACT;;;ACzNA,IAAM,gBAAgB;AACtB,IAAM,sBAAsB;AAE5B,IAAM,cAAc,oBAAI,IAAI,CAAC,KAAK,KAAK,KAAK,KAAK,GAAG,CAAC;AAI9C,SAAS,iBAAiB,MAA+B;AAC9D,MAAI,KAAK,WAAW,GAAG;AACrB,WAAO,EAAE,UAAU,MAAM,aAAa,MAAM,UAAU,MAAM,SAAS,EAAE;AAAA,EACzE;AACA,SAAO,EAAE,UAAU,MAAM,aAAa,IAAI,UAAU,OAAO,SAAS,EAAE;AACxE;AAEO,SAAS,iBACd,OACA,IACA,SACM;AACN,MAAI,MAAM,SAAU;AAEpB,QAAM,QAAQ,SAAS,SAAS;AAChC,QAAM,aAAa,SAAS,oBAAoB;AAChD,QAAM,MAAM,MAAM,SAAS;AAG3B,QAAM,cAAc,KAAK,MAAM,MAAM,OAAO,IAAI;AAChD,MAAI,iBAAiB;AACrB,MAAI,eAAe,KAAK,cAAc,KAAK;AACzC,UAAM,WAAW,MAAM,SAAS,WAAW;AAC3C,QAAI,YAAY,IAAI,QAAQ,GAAG;AAG7B,uBAAiB,aAAa,IAAI,IAAI,aAAa;AAAA,IACrD;AAAA,EACF;AAEA,QAAM,WAAW,iBAAiB;AAClC,QAAM,YAAY,KAAK,IAAI,KAAK,MAAM,MAAM,OAAO,GAAG,GAAG;AACzD,QAAM,cAAc,MAAM,SAAS,MAAM,GAAG,SAAS;AAErD,MAAI,aAAa,KAAK;AACpB,UAAM,cAAc,MAAM;AAC1B,UAAM,WAAW;AAAA,EACnB;AACF;AAEO,SAAS,mBAAmB,OAA8B;AAC/D,QAAM,cAAc,MAAM;AAC1B,QAAM,UAAU,MAAM,SAAS;AAC/B,QAAM,WAAW;AACnB;AAEO,SAAS,gBAAgB,OAAwB,SAAwB;AAC9E,QAAM,OAAO,WAAW,MAAM;AAC9B,EAAC,MAA+B,WAAW;AAC3C,QAAM,cAAc;AACpB,QAAM,UAAU;AAChB,QAAM,WAAW,KAAK,WAAW;AACnC;","names":[]}
1
+ {"version":3,"file":"index.js","names":["Signal"],"sources":["../src/graph.ts","../src/runner.ts","../src/text-reveal.ts"],"sourcesContent":["/**\n * Dialogue graph data types.\n *\n * A dialogue graph is a directed graph of nodes. Each node is one of:\n * - `line`: NPC/character speaks. Optional `next` pointer.\n * - `choice`: Player picks from options. Each option points to a node.\n * - `branch`: Condition-based routing. First truthy condition wins.\n * - `action`: Fires a callback, then advances to `next`.\n * - `end`: Terminal node. Conversation stops.\n *\n * Graphs are plain JSON — no classes, no runtime coupling.\n */\n\n// ─── Node types ──────────────────────────────────────────────────\n\nexport interface LineNode {\n readonly kind: 'line';\n /** Speaker name / id. */\n readonly speaker: string;\n /**\n * Optional portrait key resolved by the UI layer to an avatar image /\n * shape / atlas frame. The dialogue runtime is portrait-agnostic — it\n * forwards the key via `DialogueLineEvent.speakerPortraitKey`.\n */\n readonly speakerPortraitKey?: string;\n /** Text template. Supports `{var}` interpolation via the runner's variable bag. */\n readonly text: string;\n /** Next node id, or null to end. */\n readonly next: string | null;\n}\n\nexport interface ChoiceOption {\n /** Display text. Supports `{var}` interpolation. */\n readonly text: string;\n /** Node id to jump to when selected. */\n readonly next: string;\n /** Optional condition key — option is hidden when condition returns false. */\n readonly condition?: string;\n}\n\nexport interface ChoiceNode {\n readonly kind: 'choice';\n /** Optional prompt text before the choices. */\n readonly prompt?: string;\n /** Speaker for the prompt, if any. */\n readonly speaker?: string;\n readonly options: readonly ChoiceOption[];\n}\n\nexport interface BranchArm {\n /** Condition key evaluated by the runner's condition resolver. */\n readonly condition: string;\n /** Node id to jump to when condition is true. */\n readonly next: string;\n}\n\nexport interface BranchNode {\n readonly kind: 'branch';\n readonly arms: readonly BranchArm[];\n /** Fallback node id if no arm matches. Null → end. */\n readonly fallback: string | null;\n}\n\nexport interface ActionNode {\n readonly kind: 'action';\n /** Callback key. Runner looks this up in the action registry. */\n readonly action: string;\n /** Next node id after the action fires. */\n readonly next: string | null;\n}\n\nexport interface EndNode {\n readonly kind: 'end';\n}\n\nexport type DialogueNode = LineNode | ChoiceNode | BranchNode | ActionNode | EndNode;\n\n// ─── Graph ───────────────────────────────────────────────────────\n\nexport interface DialogueGraph {\n /** Unique graph identifier. */\n readonly id: string;\n /** Node id to start from. */\n readonly start: string;\n /** All nodes keyed by id. */\n readonly nodes: Record<string, DialogueNode>;\n}\n\nexport function createDialogueGraph(\n id: string,\n start: string,\n nodes: Record<string, DialogueNode>,\n): DialogueGraph {\n return { id, start, nodes };\n}\n\nexport function getNode(graph: DialogueGraph, nodeId: string): DialogueNode | undefined {\n return Object.hasOwn(graph.nodes, nodeId) ? graph.nodes[nodeId] : undefined;\n}\n","/**\n * Dialogue runner — walks a DialogueGraph, evaluating conditions,\n * interpolating variables, and emitting signals for each step.\n */\n\nimport { Signal } from '@flare-engine/events';\nimport type { ChoiceNode, DialogueGraph, LineNode } from './graph.js';\nimport { getNode } from './graph.js';\n\n// ─── Error ───────────────────────────────────────────────────────\n\nexport type DialogueErrorCode = 'missing-node' | 'cycle';\n\nexport class DialogueError extends Error {\n readonly code: DialogueErrorCode;\n /** Source node whose transition triggered the error (or the entry id when there is no source). */\n readonly nodeId: string;\n /** 'missing-node': the target id that could not be resolved. */\n readonly missingId?: string;\n /** 'cycle': the node-id chain walked before the step budget was exceeded. */\n readonly chain?: readonly string[];\n\n constructor(\n code: DialogueErrorCode,\n message: string,\n detail: { nodeId: string; missingId?: string; chain?: readonly string[] },\n ) {\n super(message);\n this.name = 'DialogueError';\n this.code = code;\n this.nodeId = detail.nodeId;\n this.missingId = detail.missingId;\n this.chain = detail.chain;\n }\n}\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport type ConditionResolver = (key: string) => boolean;\nexport type ActionHandler = (key: string) => void | Promise<void>;\nexport type VariableBag = Record<string, string | number>;\n\nexport interface DialogueLineEvent {\n readonly speaker: string;\n /** Optional portrait key forwarded verbatim from the source LineNode. */\n readonly speakerPortraitKey?: string;\n readonly text: string;\n readonly nodeId: string;\n}\n\nexport interface DialogueChoiceEvent {\n readonly prompt: string | undefined;\n readonly speaker: string | undefined;\n readonly options: readonly ActiveOption[];\n readonly nodeId: string;\n}\n\nexport interface ActiveOption {\n readonly index: number;\n readonly text: string;\n readonly next: string;\n}\n\nexport type DialogueRunnerState = 'idle' | 'running' | 'waiting-choice' | 'ended';\n\n// ─── Interpolation ───────────────────────────────────────────────\n\nexport function interpolate(template: string, vars: VariableBag): string {\n return template.replace(/\\{(\\w+)\\}/g, (_match, key: string) => {\n const val = Object.hasOwn(vars, key) ? vars[key] : undefined;\n return val !== undefined ? String(val) : `{${key}}`;\n });\n}\n\n// ─── Runner ──────────────────────────────────────────────────────\n\nexport interface DialogueRunner {\n readonly onLine: Signal<DialogueLineEvent>;\n readonly onChoice: Signal<DialogueChoiceEvent>;\n readonly onAction: Signal<string>;\n readonly onEnd: Signal<string>;\n\n readonly state: DialogueRunnerState;\n readonly currentNodeId: string | null;\n readonly graphId: string | null;\n\n start(graph: DialogueGraph, variables?: VariableBag): void;\n advance(): void;\n selectChoice(index: number): void;\n stop(): void;\n}\n\nexport interface DialogueRunnerOptions {\n conditions?: ConditionResolver;\n actions?: ActionHandler;\n /**\n * Invoked on a recoverable dialogue error — currently a transition to a\n * missing node. Default (no hook): logs a console.warn, then ends.\n */\n onError?: (err: DialogueError) => void;\n}\n\nconst STEP_BUDGET = 1000;\n\nexport function createDialogueRunner(options?: DialogueRunnerOptions): DialogueRunner {\n const onLine = new Signal<DialogueLineEvent>();\n const onChoice = new Signal<DialogueChoiceEvent>();\n const onAction = new Signal<string>();\n const onEnd = new Signal<string>();\n\n const conditions = options?.conditions ?? (() => true);\n const actions = options?.actions;\n const onError = options?.onError;\n\n let _state: DialogueRunnerState = 'idle';\n let _graph: DialogueGraph | null = null;\n let _currentNodeId: string | null = null;\n let _vars: VariableBag = {};\n let _activeOptions: ActiveOption[] = [];\n\n function setState(s: DialogueRunnerState): void {\n _state = s;\n }\n\n function end(): void {\n setState('ended');\n const graphId = _graph?.id ?? '';\n _currentNodeId = null;\n onEnd.emit(graphId);\n }\n\n function reportMissing(fromNodeId: string | null, missingId: string): void {\n const graphId = _graph?.id ?? '';\n const from = fromNodeId ? ` from node '${fromNodeId}'` : ' as its start node';\n const err = new DialogueError(\n 'missing-node',\n `Dialogue '${graphId}' references missing node '${missingId}'${from} — ending conversation.`,\n { nodeId: fromNodeId ?? missingId, missingId },\n );\n if (onError) onError(err);\n else console.warn(err.message);\n }\n\n function processLine(node: LineNode, nodeId: string): void {\n setState('running');\n const text = interpolate(node.text, _vars);\n onLine.emit({\n speaker: node.speaker,\n speakerPortraitKey: node.speakerPortraitKey,\n text,\n nodeId,\n });\n }\n\n function processChoice(node: ChoiceNode, nodeId: string): void {\n _activeOptions = [];\n for (let i = 0; i < node.options.length; i++) {\n const opt = node.options[i]!;\n if (opt.condition && !conditions(opt.condition)) continue;\n _activeOptions.push({\n index: _activeOptions.length,\n text: interpolate(opt.text, _vars),\n next: opt.next,\n });\n }\n\n if (_activeOptions.length === 0) {\n end();\n return;\n }\n\n setState('waiting-choice');\n onChoice.emit({\n prompt: node.prompt ? interpolate(node.prompt, _vars) : undefined,\n speaker: node.speaker,\n options: _activeOptions,\n nodeId,\n });\n }\n\n function run(entryId: string, fromNodeId: string | null): void {\n if (!_graph) return;\n\n let currentId: string | null = entryId;\n let from: string | null = fromNodeId;\n const chain: string[] = [];\n let steps = 0;\n\n while (currentId !== null) {\n const nodeId = currentId;\n const node = getNode(_graph, nodeId);\n\n if (!node) {\n reportMissing(from, nodeId);\n end();\n return;\n }\n\n chain.push(nodeId);\n if (++steps > STEP_BUDGET) {\n throw new DialogueError(\n 'cycle',\n `Dialogue '${_graph.id}' cycle detected — step budget exceeded (chain: …${chain.slice(-12).join('→')})`,\n { nodeId, chain: chain.slice() },\n );\n }\n\n _currentNodeId = nodeId;\n from = nodeId;\n\n switch (node.kind) {\n case 'line':\n processLine(node, nodeId);\n return;\n case 'choice':\n processChoice(node, nodeId);\n return;\n case 'branch': {\n let next: string | null = node.fallback;\n for (const arm of node.arms) {\n if (conditions(arm.condition)) {\n next = arm.next;\n break;\n }\n }\n currentId = next;\n break;\n }\n case 'action':\n onAction.emit(node.action);\n actions?.(node.action);\n currentId = node.next;\n break;\n case 'end':\n end();\n return;\n }\n }\n\n end();\n }\n\n const runner: DialogueRunner = {\n onLine,\n onChoice,\n onAction,\n onEnd,\n\n get state() {\n return _state;\n },\n get currentNodeId() {\n return _currentNodeId;\n },\n get graphId() {\n return _graph?.id ?? null;\n },\n\n start(graph: DialogueGraph, variables?: VariableBag): void {\n _graph = graph;\n _vars = variables ?? {};\n _activeOptions = [];\n setState('running');\n run(graph.start, null);\n },\n\n advance(): void {\n if (_state !== 'running' || !_graph || !_currentNodeId) return;\n const node = getNode(_graph, _currentNodeId);\n if (node?.kind !== 'line') return;\n if (node.next) {\n run(node.next, _currentNodeId);\n } else {\n end();\n }\n },\n\n selectChoice(index: number): void {\n if (_state !== 'waiting-choice') return;\n const opt = _activeOptions[index];\n if (!opt) return;\n _activeOptions = [];\n setState('running');\n run(opt.next, _currentNodeId);\n },\n\n stop(): void {\n setState('idle');\n _graph = null;\n _currentNodeId = null;\n _vars = {};\n _activeOptions = [];\n },\n };\n\n return runner;\n}\n","/**\n * Text-over-time reveal hook for dialogue lines.\n *\n * Drives a typewriter effect: call `updateTextReveal(state, dt)` each frame\n * and read `state.visibleText` for the currently revealed substring.\n * Supports per-character speed, instant-complete, and pause-on-punctuation.\n */\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TextRevealState {\n /** Full text being revealed. */\n readonly fullText: string;\n /** Characters revealed so far. */\n visibleText: string;\n /** Whether the full text has been revealed. */\n complete: boolean;\n /** Internal fractional character cursor. */\n _cursor: number;\n}\n\nexport interface TextRevealOptions {\n /** Characters per second. Default: 30. */\n speed?: number;\n /** Extra delay (in seconds) after punctuation (.!?). Default: 0.15. */\n punctuationDelay?: number;\n}\n\nconst DEFAULT_SPEED = 30;\nconst DEFAULT_PUNCT_DELAY = 0.15;\n\nconst PUNCTUATION = new Set(['.', '!', '?', ':', ';']);\n\n// ─── API ─────────────────────────────────────────────────────────\n\nexport function createTextReveal(text: string): TextRevealState {\n if (text.length === 0) {\n return { fullText: text, visibleText: text, complete: true, _cursor: 0 };\n }\n return { fullText: text, visibleText: '', complete: false, _cursor: 0 };\n}\n\nexport function updateTextReveal(\n state: TextRevealState,\n dt: number,\n options?: TextRevealOptions,\n): void {\n if (state.complete) return;\n\n const speed = options?.speed ?? DEFAULT_SPEED;\n const punctDelay = options?.punctuationDelay ?? DEFAULT_PUNCT_DELAY;\n const len = state.fullText.length;\n\n // Determine effective speed — slow down after punctuation\n const prevCharIdx = Math.floor(state._cursor) - 1;\n let effectiveSpeed = speed;\n if (prevCharIdx >= 0 && prevCharIdx < len) {\n const prevChar = state.fullText[prevCharIdx]!;\n if (PUNCTUATION.has(prevChar)) {\n // Slow down: treat the punctuation gap as taking `punctDelay` seconds\n // Effective speed = 1 / punctDelay so cursor advances ~1 char in punctDelay time\n effectiveSpeed = punctDelay > 0 ? 1 / punctDelay : speed;\n }\n }\n\n state._cursor += effectiveSpeed * dt;\n const charCount = Math.min(Math.floor(state._cursor), len);\n state.visibleText = state.fullText.slice(0, charCount);\n\n if (charCount >= len) {\n state.visibleText = state.fullText;\n state.complete = true;\n }\n}\n\nexport function completeTextReveal(state: TextRevealState): void {\n state.visibleText = state.fullText;\n state._cursor = state.fullText.length;\n state.complete = true;\n}\n\nexport function resetTextReveal(state: TextRevealState, newText?: string): void {\n const text = newText ?? state.fullText;\n (state as { fullText: string }).fullText = text;\n state.visibleText = '';\n state._cursor = 0;\n state.complete = text.length === 0;\n}\n"],"mappings":"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"}