@yagejs-addons/dialogue 0.1.0 → 0.2.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.
@@ -0,0 +1,1204 @@
1
+ import { Scene, Component } from '@yagejs/core';
2
+ import { M as MarkerToken, P as ParsedText, b as VarMap, D as DialogueScript, N as NodeId, q as DialogueNode, w as SpeakerId, d as SpeakerDef, V as VarValue, g as Command, h as CommandContext, a as VariableStorage, c as DialogueFunction, k as CommandHandler, r as DialoguePlayOptions, p as DialogueHandle } from './types-DSbBSlh7.js';
3
+ import { InputManager } from '@yagejs/input';
4
+
5
+ /**
6
+ * LineReveal — the headless typewriter clock. Given a {@link ParsedText} (markup
7
+ * already parsed into runs + an ordered {@link RevealToken} stream), a base
8
+ * `charsPerSec`, a per-line speed multiplier, and `update(dt)` ticks, it advances
9
+ * a reveal cursor in **graphemes** (the unit `markup.ts` counts and the renderer
10
+ * splits glyphs by), drains the tokens IN SOURCE ORDER (a `pause` holds, a
11
+ * `marker` fires a {@link RevealBeat}), applies per-run `[speed]`, and fires
12
+ * completion **exactly once** per line.
13
+ *
14
+ * It is renderer-free on purpose: a DOM-overlay or per-word presenter can drive
15
+ * the same reveal logic and map the grapheme cursor onto its own rendering,
16
+ * without pulling the renderer in. The default `DialogueTextView` consumes it
17
+ * and keeps only the SplitText concerns — the code-unit→glyph prefix mapping and
18
+ * per-glyph style fan-out — which LineReveal deliberately does NOT own.
19
+ *
20
+ * What it owns: the reveal cursor, the `ParsedText.tokens` drain (one ordered
21
+ * index over pauses + markers), the hold-to-fast-forward multiplier, the per-line
22
+ * and per-run (`RunStyle.speed`) speeds, and the fired-once completion. What it
23
+ * does NOT own: anything that touches a glyph, a texture, or a layout. Counts are
24
+ * graphemes throughout — it reads the pre-computed grapheme counts off
25
+ * `ParsedText` (`length`, `TextRun.graphemeCount`, a token's `atChar`) and never
26
+ * re-segments.
27
+ */
28
+
29
+ /**
30
+ * A reveal-time beat the clock emits as the cursor advances: a per-grapheme
31
+ * `tick` (one per revealed grapheme — raw, including whitespace; the host
32
+ * filters) and a `marker` when the cursor reaches a {@link MarkerToken}'s
33
+ * offset. `viaSkip` is true when the marker was drained by {@link
34
+ * LineReveal.complete} (a skip / fast-forward) rather than reached during normal
35
+ * typing, so a host can suppress a loud one-shot that only fired because of a
36
+ * skip click. Ticks are NOT emitted on a skip (replaying dozens at once would
37
+ * machine-gun).
38
+ */
39
+ type RevealBeat = {
40
+ readonly kind: "tick";
41
+ readonly index: number;
42
+ } | {
43
+ readonly kind: "marker";
44
+ readonly marker: MarkerToken;
45
+ readonly viaSkip: boolean;
46
+ };
47
+ declare class LineReveal {
48
+ private readonly charsPerSec;
49
+ private parsed;
50
+ /** Reveal cursor, in graphemes (fractional while typing). */
51
+ private cursor;
52
+ private pauseTimer;
53
+ /** Next un-drained token in `parsed.tokens` (one ordered cursor over pauses +
54
+ * markers — source order is drain order). */
55
+ private tokenIdx;
56
+ /** Graphemes already ticked (so each grapheme ticks exactly once). */
57
+ private tickCount;
58
+ /** Hold-to-fast-forward rate (1 = normal). */
59
+ private speedMul;
60
+ /** Per-line `say.speed` multiplier (1 = base). */
61
+ private lineSpeed;
62
+ private done;
63
+ private completed;
64
+ /** Fired exactly once when the line finishes revealing. The consuming view
65
+ * wires this to the session-owned reveal listener (NOT a public mutable
66
+ * field a game could clobber). */
67
+ private onComplete;
68
+ /** Per-grapheme ticks + inline markers, wired by the consuming view to the
69
+ * session-owned beat listener (like {@link onComplete}, never a public field). */
70
+ private onBeat;
71
+ /** @param charsPerSec base reveal rate (graphemes/second), scaled by the
72
+ * hold, per-line, and per-run multipliers. */
73
+ constructor(charsPerSec: number);
74
+ /**
75
+ * Register the completion listener — fires once per line, the moment the
76
+ * cursor reaches the end (or synchronously from {@link begin} for an empty
77
+ * line, or from {@link complete}). Pass `undefined` to clear.
78
+ */
79
+ setCompletionListener(listener: (() => void) | undefined): void;
80
+ /**
81
+ * Register the reveal-beat listener — per-grapheme ticks and inline markers,
82
+ * in char order, the moment the cursor reaches each. Session-owned (set once,
83
+ * like {@link setCompletionListener}); pass `undefined` to clear.
84
+ */
85
+ setBeatListener(listener: ((beat: RevealBeat) => void) | undefined): void;
86
+ /**
87
+ * Start revealing a new line. Resets the cursor, pauses, and the hold
88
+ * multiplier (a stale fast-forward must not leak into the next line — an
89
+ * active binding re-asserts it on its next poll). An **empty** line
90
+ * (`parsed.length === 0`) is complete immediately and fires the completion
91
+ * listener synchronously, matching the no-typewriter contract.
92
+ */
93
+ begin(parsed: ParsedText, lineSpeed?: number): void;
94
+ /** Hold-to-fast-forward multiplier (1 = normal, e.g. 4 while skip is held). */
95
+ setSpeedMultiplier(m: number): void;
96
+ /** Advance the reveal cursor by `dt` (ms). Honours armed pauses and per-run
97
+ * speed; fires completion once the cursor reaches the end. No-op after the
98
+ * line is done or before the first {@link begin}. */
99
+ update(dt: number): void;
100
+ /** Reveal everything now (skip-to-end on a click/tap). Drains any not-yet-fired
101
+ * markers in order so their consequences still happen (`viaSkip=true` lets a
102
+ * host suppress a loud one-shot) and blows straight through pending pauses (a
103
+ * skip ignores holds), but DISCARDS pending ticks — replaying dozens of
104
+ * typewriter blips at once would machine-gun. Fires completion. */
105
+ complete(): void;
106
+ /** Revealed grapheme count (fractional while typing). The view floors this to
107
+ * map onto its glyph prefix table. */
108
+ get revealed(): number;
109
+ /** True once the line is fully revealed (also true for an empty line). */
110
+ isComplete(): boolean;
111
+ /** True while glyphs are still appearing. */
112
+ isRevealing(): boolean;
113
+ private finish;
114
+ /**
115
+ * Drain tokens whose offset the cursor has reached, IN SOURCE ORDER. A `marker`
116
+ * emits a beat; a `pause` arms the hold, clamps the cursor to its offset, and
117
+ * STOPS the drain for this frame (a one-frame advance can overshoot the offset,
118
+ * so the clamp keeps glyphs past the beat from popping in early, and a later
119
+ * token waits until the hold resumes). `viaSkip` (from {@link complete}) tags
120
+ * drained markers and blows straight through pauses without holding. Monotonic
121
+ * `tokenIdx` → each token is handled exactly once.
122
+ */
123
+ private drainTokens;
124
+ /** Emit a `tick` for each grapheme newly revealed since the last call (raw —
125
+ * no whitespace test; the host filters). Multiple in order on a large-dt
126
+ * frame; `tickCount` is monotonic so none repeat. */
127
+ private emitTicks;
128
+ /** Reveal speed multiplier for whichever run the cursor currently sits in. */
129
+ private runSpeedAt;
130
+ }
131
+
132
+ /**
133
+ * `defineScript` — the TS-first authoring path. An identity function at
134
+ * runtime; at compile time it captures the script's declared variable value
135
+ * types (from their {@link DialogueScript.declare} defaults), branding the
136
+ * returned script so `play()` can hand back a typed {@link DialogueHandle}.
137
+ *
138
+ * JSON scripts (a plain {@link DialogueScript} literal) get the exact same
139
+ * runtime rules via load-/play-time validation — the brand is a pure
140
+ * compile-time convenience and never exists at runtime. Inference stays shallow:
141
+ * it lives here and at the `play()` boundary, never threaded through the
142
+ * session/controller internals.
143
+ *
144
+ * const script = defineScript({
145
+ * id: "shop", start: "n",
146
+ * declare: { greeted: false, gold: 0 }, // greeted: boolean, gold: number
147
+ * nodes: { n: { id: "n", steps: [{ kind: "end" }] } },
148
+ * });
149
+ * const handle = controller.play(script); // content-only
150
+ * handle.setVar("greeted", true); // ^ key is typed to keyof declare
151
+ */
152
+
153
+ declare const VARS_BRAND: unique symbol;
154
+ /** Phantom carrier of a script's captured variable types. Never present at
155
+ * runtime — only `defineScript`'s cast asserts it. */
156
+ interface ScriptTypes<V extends VarMap> {
157
+ readonly [VARS_BRAND]: V;
158
+ }
159
+ /** A {@link DialogueScript} branded with its declared variable types. */
160
+ type TypedScript<V extends VarMap> = DialogueScript & ScriptTypes<V>;
161
+ /** Widen a declared default's literal type to its base — a variable declared
162
+ * `false` is a boolean (accepts `true`), `0` is a number, `"x"` is a string. A
163
+ * `null` default is untyped, so it widens to the full {@link VarValue} union. */
164
+ type Widen<T extends VarValue> = T extends string ? string : T extends number ? number : T extends boolean ? boolean : VarValue;
165
+ type WidenVars<V extends VarMap> = {
166
+ [K in keyof V]: Widen<V[K]>;
167
+ };
168
+ declare function defineScript<V extends VarMap = Record<never, never>>(script: Omit<DialogueScript, "declare"> & {
169
+ readonly id: string;
170
+ readonly start?: NodeId;
171
+ readonly nodes: Record<NodeId, DialogueNode>;
172
+ readonly speakers?: Record<SpeakerId, SpeakerDef>;
173
+ readonly declare?: V;
174
+ }): TypedScript<WidenVars<V>>;
175
+ /** Declared variable types captured for a script (the loose {@link VarMap} for a
176
+ * plain JSON script). Drives the typed {@link DialogueHandle} `play()` returns. */
177
+ type VarsOf<S> = S extends ScriptTypes<infer V> ? V : VarMap;
178
+
179
+ /**
180
+ * i18n seam. The runtime never reaches for a translation library directly —
181
+ * it asks an {@link I18nAdapter}. Ship the identity adapter (literal text +
182
+ * `{param}` interpolation) by default; wrap i18next / FormatJS / your own
183
+ * string table in a ~10-line adapter to localise without touching the engine.
184
+ */
185
+ interface I18nAdapter {
186
+ /** Current locale tag, e.g. "en", "fr-CA". Informational. */
187
+ readonly locale: string;
188
+ /**
189
+ * Resolve a string. `key` is the translation key when the script provides
190
+ * one; `fallback` is the authored literal text. `params` feed interpolation.
191
+ * Implementations should return localised markup-bearing text.
192
+ */
193
+ t(key: string | undefined, fallback: string, params?: Readonly<Record<string, unknown>>): string;
194
+ }
195
+ /**
196
+ * No-op adapter: returns the authored literal, interpolating `{name}` tokens
197
+ * from `params`. This is what runs until a real i18n backend is plugged in.
198
+ */
199
+ declare class IdentityI18n implements I18nAdapter {
200
+ readonly locale: string;
201
+ constructor(locale?: string);
202
+ t(_key: string | undefined, fallback: string, params?: Readonly<Record<string, unknown>>): string;
203
+ }
204
+ /** Replace `{token}` with `params.token`; leaves unknown tokens untouched.
205
+ * Own-property check only — `{constructor}`/`{toString}` must not stringify
206
+ * inherited Object.prototype members. */
207
+ declare function interpolate(text: string, params: Readonly<Record<string, unknown>>): string;
208
+
209
+ /**
210
+ * Extensible presentation channels — the open-ended companion to the built-in
211
+ * typed trio (text / choices / avatar / chrome). A host *registers* one of these
212
+ * on a running conversation to add behaviour the addon doesn't own (voice-over,
213
+ * a shop reacting to a `buy` command, a camera shake, a history recorder).
214
+ *
215
+ * Pixi-free, like the rest of `core/`: a channel that needs the scene also
216
+ * implements {@link Mountable} (re-exported from the root barrel), but this
217
+ * interface itself imposes no engine dependency.
218
+ */
219
+
220
+ /**
221
+ * An optional-hook channel the host registers via
222
+ * {@link DialogueController.addChannel} / {@link DialogueSession.addChannel} (or
223
+ * the controller's `channels` ctor array). **Every method is optional** — a
224
+ * one-method observer (a shop reacting to `command`, a history recorder reacting
225
+ * to `revealComplete`) implements just what it needs and ignores the rest.
226
+ *
227
+ * The {@link DialogueSession} fans its cross-cutting stream out to every
228
+ * registered channel at the same sites it drives the trio — `present`,
229
+ * `command`, `clear`, `setVisible`, `setPaused`, `completeReveal`, `update` —
230
+ * each call wrapped so a throwing channel is routed to the session's `onError`
231
+ * and never breaks the conversation. The trio stays trusted/unwrapped.
232
+ *
233
+ * Coupling is deliberately one-directional: the ONLY value a channel hands back
234
+ * is {@link isRevealComplete}, a boolean the session folds into the auto-advance
235
+ * gate. Everything else is **consequences-out** — a channel changes game state
236
+ * through {@link CommandContext.setVar} (write-only) and reads it back through
237
+ * the host-held `DialogueHandle.getVars()`, never through the session.
238
+ */
239
+ interface DialogueExtraChannel {
240
+ /**
241
+ * A say line was presented — called right after the text channel's `present`
242
+ * and before the line's `show` commands. Read `line.voice` / `line.meta`
243
+ * here. NOT called for choice prompts (only say lines carry a voice/reveal).
244
+ */
245
+ present?(line: PresentedLine): void;
246
+ /**
247
+ * The current say line finished its typewriter reveal (right after the host's
248
+ * `onRevealCompleted`). For a history / analytics recorder that commits a line
249
+ * once it's fully shown. Carries the same {@link PresentedLine} as `present`.
250
+ */
251
+ revealComplete?(line: PresentedLine): void;
252
+ /**
253
+ * A non-built-in command fired — mirrors the host `onCommand`, with the same
254
+ * exclusion (never `set`, which the runner owns). A shop channel reacts to a
255
+ * `buy` command here. (A mid-line face change is an `[expression=…/]` reveal
256
+ * marker on {@link revealBeat}, not a command.)
257
+ */
258
+ command?(command: Command, ctx: CommandContext): void;
259
+ /** The conversation cleared (a `stop()` or its natural end) — reset any
260
+ * per-conversation state. Distinct from {@link dispose} (final teardown). */
261
+ clear?(): void;
262
+ /** The whole dialogue UI was shown / hidden (the host `setHidden` lever). */
263
+ setVisible?(visible: boolean): void;
264
+ /** The conversation was paused / resumed — freezes player-facing time. A voice
265
+ * channel pauses its clip here. */
266
+ setPaused?(paused: boolean): void;
267
+ /** The player skipped the typewriter (advance-while-revealing) or fast-forwarded
268
+ * the section (skip) — cut a clip, drain a queued effect. */
269
+ completeReveal?(): void;
270
+ /**
271
+ * A reveal beat fired during the current say line's typewriter — a
272
+ * per-grapheme `tick` or an inline `[name k=v/]` `marker`. A typewriter-SFX
273
+ * channel blips on ticks; a CameraEffects channel reacts to a `[shake/]`
274
+ * marker. Fires once per grapheme (hundreds per line), so keep it cheap.
275
+ * Markers also reach the host via `DialogueRevealMarkerEvent` and the avatar
276
+ * channel; this is the registered-channel path to the same stream.
277
+ */
278
+ revealBeat?(beat: RevealBeat): void;
279
+ /** Per-frame tick. Already gated by the session pause (not called while
280
+ * paused), so a dt-driven timer freezes for free. */
281
+ update?(dt: number): void;
282
+ /** Final teardown — called by the {@link DialogueSession.addChannel} disposer
283
+ * and the controller's `onDestroy`. Release anything {@link clear} doesn't. */
284
+ dispose?(): void;
285
+ /**
286
+ * Auto-advance gate. While this returns `false` the session's auto-advance
287
+ * clock is frozen (a manual advance is **always** allowed). Omit it to never
288
+ * gate — a pure observer. The session **arms** its clock on the TEXT reveal
289
+ * but **counts down** only once text AND every registered gater report
290
+ * complete, so a line auto-advances at `max(clipEnd, revealEnd)` with no
291
+ * duration plumbing. Must be a cheap, total boolean read (never throws — it is
292
+ * polled every frame and, unlike the fanned-out hooks, is not wrapped).
293
+ */
294
+ isRevealComplete?(): boolean;
295
+ }
296
+
297
+ /**
298
+ * DialogueSession — the headless orchestrator. It binds a {@link DialogueRunner}
299
+ * to a set of presentation *channels* (text / choices / avatar / chrome) and
300
+ * sequences a conversation: resolve i18n + markup, drive the typewriter, gate on
301
+ * reveal-completion, run the auto-advance clock, and book-keep choice selection.
302
+ *
303
+ * It is engine-agnostic (zero `@yagejs` imports): the channels are semantic
304
+ * interfaces — `present(line)`, `highlight(i)`, `setSpeaking(bool)` — with no
305
+ * pixels, no input, and no world entities. A YAGE host (`DialogueController`)
306
+ * supplies concrete channels, an `InputBinding`, and pumps `update(dt)`; a
307
+ * headless test can supply stubs. The public API is input-agnostic
308
+ * (`advance / moveSelection / confirm / choose / setFastForward`) so any device
309
+ * binding maps onto it.
310
+ *
311
+ * Observation is via callbacks (`onLine`, `onChoiceMade`, `onCommand`, …) so a
312
+ * host can forward to engine events or a history recorder without the Session
313
+ * knowing about either.
314
+ */
315
+
316
+ /** One previewed line: speaker name + plain (markup-stripped) text. */
317
+ interface PreviewedLine {
318
+ readonly speaker?: string | undefined;
319
+ readonly text: string;
320
+ }
321
+ /** Resolved speaker descriptor on a presented line (for nameplates / anchoring). */
322
+ interface SpeakerView {
323
+ readonly id: string;
324
+ readonly name?: string | undefined;
325
+ readonly color?: number | undefined;
326
+ }
327
+ /** A fully-resolved line (i18n + markup already applied) handed to presenters. */
328
+ interface PresentedLine {
329
+ /** Who's speaking (if anyone) — lets world presenters anchor to their actor. */
330
+ readonly speaker?: SpeakerView | undefined;
331
+ readonly text: ParsedText;
332
+ /** Per-line reveal-speed multiplier (`say.speed`, default 1). */
333
+ readonly speed: number;
334
+ /** Opaque preset name for per-line layout/variant (presenter interprets). */
335
+ readonly view?: string | undefined;
336
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
337
+ /** Voice-clip id (audio handler interprets; reveal may sync to it). */
338
+ readonly voice?: string | undefined;
339
+ }
340
+ /** One choice row, resolved to a display label. Position = array index. */
341
+ interface PresentedChoice {
342
+ readonly label: string;
343
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
344
+ /** True for a visible-but-disabled row (its condition failed and its
345
+ * `presentation` is `"disabled"`). Presenters render it greyed and
346
+ * non-selectable; the Session's nav/confirm skip it. */
347
+ readonly disabled?: boolean | undefined;
348
+ /** i18n-resolved reason for a {@link disabled} row, shown beside it where the
349
+ * layout allows (e.g. "Requires the rusty key"). */
350
+ readonly disabledReason?: string | undefined;
351
+ }
352
+ /**
353
+ * Body-text channel. Owns reveal timing (the Session only learns *that* a line
354
+ * finished, via the reveal listener, never *when* a glyph appears). An
355
+ * accessibility / no-typewriter presenter is
356
+ * `present(){ draw(); this.revealListener?.() }`.
357
+ */
358
+ interface TextChannel {
359
+ present(line: PresentedLine): void;
360
+ /** Reveal everything immediately (skip-to-end). */
361
+ completeReveal(): void;
362
+ isRevealComplete(): boolean;
363
+ isRevealing(): boolean;
364
+ /** Hold-to-fast-forward rate flag (1 = normal). */
365
+ setSpeedMultiplier(multiplier: number): void;
366
+ /**
367
+ * Show or hide the body text **without** disturbing reveal progress — a
368
+ * cutscene can hide mid-typewriter and show again to resume mid-line. Purely
369
+ * visual: the reveal cursor, timers, and the laid-out line are untouched.
370
+ */
371
+ setVisible(visible: boolean): void;
372
+ update(dt: number): void;
373
+ clear(): void;
374
+ /**
375
+ * Register the reveal-completed listener. The Session owns this seam — it
376
+ * registers its handler once in the ctor — so a game can't clobber the wiring
377
+ * by assigning a public field. Pass `undefined` to clear.
378
+ */
379
+ setRevealListener(listener: (() => void) | undefined): void;
380
+ /**
381
+ * Register the reveal-beat listener — per-grapheme typewriter ticks and inline
382
+ * `[name k=v/]` markers, in char order, as the reveal cursor reaches each.
383
+ * Session-owned like {@link setRevealListener} (registered once in the ctor);
384
+ * pass `undefined` to clear. A no-typewriter presenter that reveals instantly
385
+ * may omit beats; the bundled view forwards the headless {@link LineReveal}
386
+ * clock's beats.
387
+ */
388
+ setBeatListener(listener: ((beat: RevealBeat) => void) | undefined): void;
389
+ }
390
+ /** Per-choice presentation context, so a choice list can route/anchor the same
391
+ * way lines do (box list vs a bubble over `speaker`'s actor) and optionally
392
+ * render the prompt itself. */
393
+ interface ChoiceContext {
394
+ readonly view?: string | undefined;
395
+ readonly speaker?: SpeakerView | undefined;
396
+ /** The (resolved + parsed) prompt, made available so a presenter can render
397
+ * it itself (see {@link ChoiceChannel.ownsPrompt}). Empty when no prompt. */
398
+ readonly prompt?: ParsedText | undefined;
399
+ /** The choice step's opaque `meta` bag, passed straight through. A custom
400
+ * presenter reads it to render extras the model doesn't own — e.g. the
401
+ * timed-choice recipe's `{ timeoutMs }` for a countdown. */
402
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
403
+ }
404
+ /** Choice channel. Selection nav lives in the Session; pointer/touch commits
405
+ * come back through `onChoiceChosen(position)`. `context` carries the choice's
406
+ * view/speaker/prompt so a composite presenter can route (box vs bubble). */
407
+ interface ChoiceChannel {
408
+ present(choices: readonly PresentedChoice[], context?: ChoiceContext): void;
409
+ highlight(position: number): void;
410
+ /** Show or hide the choice list without clearing it — state-preserving,
411
+ * so the selection and laid-out rows survive a hide/show round-trip. */
412
+ setVisible(visible: boolean): void;
413
+ clear(): void;
414
+ onChoiceChosen?: (position: number) => void;
415
+ /**
416
+ * If true for this `context`, the presenter draws the prompt itself (e.g. a
417
+ * self-contained bubble panel), so the Session suppresses the chrome + body-
418
+ * text prompt. Default false → the chrome/text show the prompt (box body).
419
+ */
420
+ ownsPrompt?(context?: ChoiceContext): boolean;
421
+ }
422
+ /** Avatar channel — who's talking + their expression + talk state. */
423
+ interface AvatarChannel {
424
+ setSpeaker(speaker: SpeakerDef | undefined): void;
425
+ setExpression(expression: string | undefined): void;
426
+ setSpeaking(speaking: boolean): void;
427
+ /**
428
+ * Optional per-line hook (mirrors {@link ChromeChannel.present}) so an avatar
429
+ * can be **line-driven**: read the line's `meta` (e.g. `portrait` / `side` /
430
+ * `presence`) to pick an image/side/presence, beyond what `setSpeaker` carries.
431
+ * The Session calls it on each say/choice line alongside `setSpeaker`, and
432
+ * with `undefined` when the conversation clears (stop/end). A reflowing in-box
433
+ * avatar registers a text inset here so the body text reflows around it. Most
434
+ * avatars (portrait, scene-figure) omit it.
435
+ */
436
+ present?(line: PresentedLine | undefined): void;
437
+ /**
438
+ * Optional inline-marker hook (sibling to {@link present} / {@link setVisible}).
439
+ * The Session fans every `[name k=v/]` reveal marker here so an avatar can
440
+ * interpret the ones it owns — the bundled portrait/scene presenters read
441
+ * `[expression=…/]` and call their own `setExpression`. The Session name-matches
442
+ * NOTHING; an avatar ignores markers it doesn't recognize. `viaSkip` markers
443
+ * (drained by a skip) arrive here too — the avatar collapses to the last one.
444
+ */
445
+ marker?(marker: MarkerToken): void;
446
+ /** Optional visibility gate — a portrait hides during a cutscene; a
447
+ * scene-figure avatar (a world NPC the game owns) omits it and stays put. */
448
+ setVisible?(visible: boolean): void;
449
+ update(dt: number): void;
450
+ }
451
+ /** Chrome channel — frame / nameplate / continue caret (everything but body
452
+ * text and choices). `present?` lets a chrome react to per-line variants;
453
+ * `present(undefined)` means "no line — clear the chrome's content". */
454
+ interface ChromeChannel {
455
+ /** Set the speaker name, or `undefined` for **no name** — NOT a covert
456
+ * hide-all; visibility is governed solely by {@link setVisible}. */
457
+ setNameplate(name: string | undefined, color?: number): void;
458
+ setContinueVisible(visible: boolean): void;
459
+ /**
460
+ * Show or hide the whole chrome — the honest visibility verb the Session
461
+ * drives (a composite restores its active variant on show). State-preserving.
462
+ */
463
+ setVisible(visible: boolean): void;
464
+ /**
465
+ * React to a per-line variant (`meta.chrome`, `meta.position`). Called
466
+ * **before** {@link TextChannel.present} for the same line, so a composite
467
+ * selects its active variant and a layout owner commits the frame the text
468
+ * then reads. `present(undefined)` means "no line — clear the chrome's content".
469
+ */
470
+ present?(line: PresentedLine | undefined): void;
471
+ update(dt: number): void;
472
+ }
473
+ /**
474
+ * The presentation channels a {@link DialogueSession} drives. Writing a custom
475
+ * presenter (a DOM overlay, a ui-react chrome) means implementing these — so the
476
+ * **call-order contract** the Session guarantees is documented here, on the
477
+ * interfaces themselves:
478
+ *
479
+ * Per say line, the Session calls (in this order):
480
+ * 1. `chrome.setNameplate(name)` / `chrome.setContinueVisible(false)`
481
+ * 2. `avatar.setSpeaker` / `setExpression` / `setSpeaking`, then `avatar.present?(line)`
482
+ * 3. `chrome.present?(line)` — **before** the text, so a composite picks the
483
+ * active variant first and a layout owner commits the frame the text reads
484
+ * 4. `text.present(line)` — render + start revealing
485
+ * 5. (each channel's `setVisible(shown)` reflects the host-hidden lever)
486
+ *
487
+ * Then, exactly once, when the line finishes revealing, the text channel fires
488
+ * the listener registered via {@link TextChannel.setRevealListener} (the
489
+ * Session owns that seam — never a public field). For an **empty** line the
490
+ * completion fires synchronously inside `text.present`.
491
+ *
492
+ * Per choice the order mirrors a line (chrome/avatar/text for the optional
493
+ * prompt) and then `choices.present(options, context)`. If a presenter
494
+ * {@link ChoiceChannel.ownsPrompt | owns the prompt}, the Session clears the
495
+ * chrome + body instead of step 3/4.
496
+ *
497
+ * `setBox`/geometry is always applied **before** `present` for that line (a
498
+ * presenter that lays out off a region reads the committed region in `present`).
499
+ */
500
+ interface DialogueChannels {
501
+ readonly text: TextChannel;
502
+ readonly choices: ChoiceChannel;
503
+ readonly avatar?: AvatarChannel | undefined;
504
+ readonly chrome?: ChromeChannel | undefined;
505
+ }
506
+ interface DialogueSessionOptions {
507
+ readonly i18n?: I18nAdapter | undefined;
508
+ /** Hold-to-fast-forward multiplier. Default 4. */
509
+ readonly skipMultiplier?: number | undefined;
510
+ /**
511
+ * The variable storage installed for every `play()`. Persists across
512
+ * plays. Omit for a zero-config {@link MemoryVariableStorage}; supply your own
513
+ * or {@link compose} several to bridge game state. A per-`play()`
514
+ * `overrides.storage` replaces it for that conversation.
515
+ */
516
+ readonly storage?: VariableStorage | undefined;
517
+ /** Argument-capable read functions (`has_item("key")`) for conditions/`set`
518
+ * expressions. Per-`play()` `overrides.functions` merge on top. */
519
+ readonly functions?: Readonly<Record<string, DialogueFunction>> | undefined;
520
+ /** Command handlers (`type` → handler). Per-`play()` `overrides.commands`
521
+ * merge on top (call site wins). */
522
+ readonly commands?: Readonly<Record<string, CommandHandler>> | undefined;
523
+ /** Catch-all for command types with no explicit handler. */
524
+ readonly fallbackCommand?: CommandHandler | undefined;
525
+ /** Non-fatal runtime diagnostics (e.g. a `set` to a read-only `cells`
526
+ * accessor that was ignored). The controller routes these to the engine
527
+ * logger; a bare session may handle or drop them. */
528
+ readonly onError?: ((message: string, error: unknown) => void) | undefined;
529
+ readonly onStarted?: (e: {
530
+ scriptId: string;
531
+ }) => void;
532
+ /** Plain (markup-stripped) line text — for logs / a11y / history. */
533
+ readonly onLine?: (e: {
534
+ speaker?: string | undefined;
535
+ text: string;
536
+ }) => void;
537
+ readonly onChoiceShown?: (e: {
538
+ options: readonly string[];
539
+ }) => void;
540
+ readonly onChoiceMade?: (e: {
541
+ index: number;
542
+ text: string;
543
+ }) => void;
544
+ /**
545
+ * Observation hook fired for every non-built-in command (`set` is runner-owned
546
+ * and never reaches it) — the host forwards it to {@link DialogueCommandEvent}.
547
+ * The actual *handling* (and any `blocking` await) is the binding's `commands`
548
+ * map / `fallbackCommand`, not this; this returns nothing.
549
+ */
550
+ readonly onCommand?: (command: Command, ctx: CommandContext) => void;
551
+ readonly onEnded?: (e: {
552
+ scriptId: string;
553
+ }) => void;
554
+ /** A line finished its typewriter reveal — the "typing finished" hook
555
+ * (audio blip, etc.). Plain (markup-stripped) text, like {@link onLine}. */
556
+ readonly onRevealCompleted?: (e: {
557
+ speaker?: string | undefined;
558
+ text: string;
559
+ }) => void;
560
+ /**
561
+ * Per-grapheme typewriter tick — a direct CALLBACK only (NOT forwarded to an
562
+ * entity event; it fires hundreds of times per line). `index` is the 0-based
563
+ * grapheme index just revealed, raw (whitespace included — the host filters if
564
+ * it only wants a blip on visible glyphs). Wire a typewriter SFX here. Not
565
+ * fired on a skip / fast-forward (pending ticks are discarded).
566
+ */
567
+ readonly onRevealTick?: ((index: number) => void) | undefined;
568
+ /**
569
+ * An inline `[name k=v/]` marker reached its char offset during reveal — the
570
+ * host forwards it to {@link DialogueRevealMarkerEvent}. `viaSkip` is true when
571
+ * a skip/complete drained it (a host can suppress a loud one-shot that only
572
+ * fired because the player skipped). The Session name-matches NO marker: the
573
+ * avatar channel interprets `[expression=…/]` itself; every other name flows
574
+ * opaquely to the host / registered channels.
575
+ */
576
+ readonly onRevealMarker?: (marker: MarkerToken, viaSkip: boolean) => void;
577
+ /** The choice cursor moved — keyboard nav AND pointer hover both funnel here
578
+ * `index` is the resolved option index, `text` its plain label. */
579
+ readonly onSelectionChanged?: (e: {
580
+ index: number;
581
+ text: string;
582
+ }) => void;
583
+ /** The player skipped the current section — for skip-used analytics. */
584
+ readonly onSkipUsed?: (e: {
585
+ scriptId: string;
586
+ }) => void;
587
+ /** A line auto-advanced via the auto-advance clock — distinct from a
588
+ * manual advance so a game can tell them apart. */
589
+ readonly onAutoAdvance?: (e: {
590
+ scriptId: string;
591
+ }) => void;
592
+ }
593
+ declare class DialogueSession {
594
+ private readonly channels;
595
+ private readonly opts;
596
+ private readonly i18n;
597
+ private readonly skipMul;
598
+ /** Controller-installed environment (persists across plays); per-`play()`
599
+ * overrides are layered on top into the resolved fields below. */
600
+ private readonly defaultStorage;
601
+ private readonly defaultFunctions;
602
+ private readonly defaultCommands;
603
+ private readonly defaultFallback;
604
+ private storage;
605
+ private functions;
606
+ private commands;
607
+ private fallbackCommand;
608
+ private runner;
609
+ private script;
610
+ private mode;
611
+ private scriptId;
612
+ private saying;
613
+ private autoTimer;
614
+ /** Default auto-advance delay (ms) applied to lines without their own
615
+ * `autoAdvanceMs`. `null` = off (manual advance). Set via {@link setAutoAdvance}. */
616
+ private autoAdvanceDefault;
617
+ private resolved;
618
+ private selected;
619
+ /** Count of in-flight blocking line-command batches (show/afterReveal/advance).
620
+ * Input is gated while > 0. An ownership counter (not a shared boolean) so an
621
+ * overlapping batch resolving — e.g. the afterReveal batch finishing while a
622
+ * long blocking `show` command is still awaited — can't drop a gate it
623
+ * doesn't own. */
624
+ private blockedCount;
625
+ /** True between an advance request and the runner stepping off the line —
626
+ * guards against a second advance double-firing `advance`-timed commands. */
627
+ private advancing;
628
+ /** True once the current line's `advance`-timed commands have fired, so a
629
+ * second advance() while the runner is still stepping (e.g. awaiting a
630
+ * blocking command step) can't re-fire them against the stale line. */
631
+ private advanceFired;
632
+ /** True once the current line's `afterReveal`-timed commands have fired
633
+ * (normally via handleRevealComplete; skip() fires them early when the line
634
+ * hasn't finished revealing). */
635
+ private afterRevealFired;
636
+ /** Latched by the first confirm() until the runner produces its next state
637
+ * (handleSay/handleChoice/handleEnd), so mashing confirm while the runner
638
+ * awaits the option's blocking commands can't emit duplicate onChoiceMade
639
+ * events (`mode` stays "choosing" for that whole window). */
640
+ private confirming;
641
+ /** Bumped by every stop()/play(). A suspended async continuation from a prior
642
+ * conversation captures this and bails on resume if it changed, so it can't
643
+ * drive (advance / show the caret on) the runner of a *new* conversation. */
644
+ private generation;
645
+ /** `setHidden` — visual only; gates every channel's `setVisible`. Survives
646
+ * `stop()`/`play()` (a host that hides for a cutscene and forgets to unhide
647
+ * gets what it asked for) — so it is deliberately NOT reset by `stop()`. */
648
+ private hidden;
649
+ /** `setPaused` — freezes the update loop AND the input-agnostic API. State is
650
+ * left fully intact (no generation bump); also host-level, survives replays. */
651
+ private paused;
652
+ /** Whether the chrome / body text are part of the CURRENT choice's layout
653
+ * (false when a self-contained bubble panel owns the prompt) — remembered so
654
+ * {@link applyVisibility} can recompute after a hide toggle mid-choice. */
655
+ private choiceShowsChrome;
656
+ private choiceShowsBody;
657
+ /** Plain (speaker, text) of the line on screen — for the reveal-completed
658
+ * event, which fires after `present` has discarded the resolved string. */
659
+ private currentLine;
660
+ /** The full {@link PresentedLine} on screen — handed to an extra channel's
661
+ * `revealComplete` (the session discards the local `line` after present). */
662
+ private currentPresented;
663
+ /** Host-registered extra channels (Voice / Shop / CameraEffects / History).
664
+ * The session fans its cross-cutting stream to these alongside the typed trio
665
+ * and folds their `isRevealComplete()` into the auto-advance gate. */
666
+ private readonly extras;
667
+ constructor(channels: DialogueChannels, opts?: DialogueSessionOptions);
668
+ /**
669
+ * Begin a conversation. `play(script)` is **content-only** — the storage,
670
+ * functions, and commands are installed on the session; `overrides` layers
671
+ * per-conversation specifics on top (a scoped `storage`, extra `functions` /
672
+ * `commands`). Declared defaults seed into the storage **only if absent**
673
+ * (game-linked values win); variables persist across plays. Returns a
674
+ * generation-stamped {@link DialogueHandle} for live `setVar` / `getVars`.
675
+ */
676
+ play<S extends DialogueScript>(rawScript: S, overrides?: DialoguePlayOptions): DialogueHandle<VarsOf<S>>;
677
+ isActive(): boolean;
678
+ isChoosing(): boolean;
679
+ /**
680
+ * Register an extra channel (Voice / Shop / CameraEffects / History) — the
681
+ * open-ended companion to the built-in trio. It receives the cross-cutting
682
+ * stream (`present` / `command` / `clear` / `setVisible` / `setPaused` /
683
+ * `completeReveal` / `update`) and can gate auto-advance via
684
+ * `isRevealComplete()`. Returns a disposer that unregisters **and** disposes
685
+ * it. On register the channel catches up the current `setVisible` / `setPaused`
686
+ * lever state ONLY — no content replay (replaying `present` would re-trigger a
687
+ * voice clip). Safe to call mid-conversation.
688
+ */
689
+ addChannel(ch: DialogueExtraChannel): () => void;
690
+ /**
691
+ * Hide or show the whole dialogue UI — purely visual, state-preserving.
692
+ * Drives every channel's `setVisible`; the conversation keeps running
693
+ * underneath (reveal, timers, cursor intact). Host-level and **persistent**:
694
+ * it survives `stop()` and the next `play()`, so a cutscene that hides and
695
+ * forgets to unhide stays hidden — call `setHidden(false)` to restore.
696
+ */
697
+ setHidden(hidden: boolean): void;
698
+ /** True while the UI is hidden via {@link setHidden}. */
699
+ isHidden(): boolean;
700
+ /**
701
+ * Freeze or resume the conversation — `update()` no-ops (reveal,
702
+ * auto-advance clock, caret blink, avatar anim all freeze since they are
703
+ * dt-driven) and the input-agnostic API (`advance`/`confirm`/`choose`/
704
+ * `moveSelection`/`selectAt`/`skip`) no-ops. State is left fully intact: no
705
+ * generation bump, and `lineBlocked`/`advancing`/`autoTimer` survive. It does
706
+ * NOT block host-driven writes (`handle.setVar` / `ctx.setVar` / storage) —
707
+ * only player-facing time + input freeze. Host-level and persistent like hide.
708
+ */
709
+ setPaused(paused: boolean): void;
710
+ /** True while the conversation is frozen via {@link setPaused}. */
711
+ isPaused(): boolean;
712
+ /**
713
+ * Push the current desired visibility to every channel: a channel shows when
714
+ * it has content for the current mode AND the host hasn't hidden the UI. The
715
+ * single visibility authority — `setHidden` and every line/choice transition
716
+ * route through here, so the host-hidden lever composes cleanly with per-line
717
+ * content and a custom chrome (which may not implement the optional `present`)
718
+ * is still reliably hidden by the explicit `setVisible(false)`.
719
+ */
720
+ private applyVisibility;
721
+ /** Clear every presentation channel's content — text, choices, chrome
722
+ * (nameplate + caret + line), the avatar, and the registered extras. The
723
+ * shared teardown for {@link stop} and the ended state; it touches no session
724
+ * bookkeeping or visibility (the caller resets its own state, then
725
+ * {@link goIdle} reasserts visibility). */
726
+ private clearAllChannels;
727
+ /** Drop to a quiescent presentation state (`mode` "idle" or "ended"): reset the
728
+ * per-line/choice bookkeeping, clear every channel, and reassert visibility
729
+ * (idle/ended → nothing shown, honestly via each channel's `setVisible`,
730
+ * preserving the host-hidden lever). The caller owns any further reset —
731
+ * {@link stop} also abandons the runner + timing latches. */
732
+ private goIdle;
733
+ /** Abandon the current conversation and reset to idle (clears all visuals).
734
+ * Useful for ambient/eavesdrop dialogue that should stop when out of range. */
735
+ stop(): void;
736
+ update(dt: number): void;
737
+ /** True while any blocking line-command batch is awaited (input is gated). */
738
+ private get lineBlocked();
739
+ /**
740
+ * The auto-advance gate: the text reveal AND every registered extra channel
741
+ * that *gates* (implements `isRevealComplete`) report complete. The clock is
742
+ * armed on the text reveal alone (see `handleRevealComplete`/`setAutoAdvance`)
743
+ * but only counts down once this is true — so a voice clip outlasting the
744
+ * typewriter holds the line for `max(clipEnd, revealEnd)` with no duration
745
+ * plumbing. A channel without the method never gates (a pure observer).
746
+ */
747
+ private allRevealsComplete;
748
+ /**
749
+ * The storage read view for `{token}` interpolation at *this* present-time.
750
+ * Materialized per evaluation so an earlier command's `set` shows up on a
751
+ * later line; already-shown lines never re-render.
752
+ */
753
+ private readView;
754
+ /** Primary action. Saying → reveal-all if typing, else next line. Choosing → confirm. */
755
+ advance(): void;
756
+ /** Fire any `advance`-timed line commands, then step the runner off the line.
757
+ * `advancing` is held for the whole turn so a second advance can't re-fire
758
+ * the (possibly non-blocking) `advance` commands before the runner steps. */
759
+ private advanceLine;
760
+ /**
761
+ * Fast-forward the current section: run intervening commands (in skip mode)
762
+ * without presenting, stopping at the next choice or the end. No-op unless a
763
+ * line is showing.
764
+ */
765
+ skip(): void;
766
+ /**
767
+ * Fire the *displayed* line's not-yet-fired batches in skip mode — the runner
768
+ * fires every skipped line's commands for world reconstruction, so dropping
769
+ * the current line's `afterReveal`/`advance` batches would diverge from
770
+ * normal play — then fast-forward the runner.
771
+ */
772
+ private skipLine;
773
+ /**
774
+ * Side-effect-free lookahead: the lines a node would show along its linear
775
+ * path — following `goto` and conditional `command` jumps using the *current*
776
+ * variable snapshot — stopping at the first choice or the end. Runs no
777
+ * commands and mutates nothing. For a "skip with a summary" affordance.
778
+ */
779
+ preview(nodeId: string, limit?: number): PreviewedLine[];
780
+ /** Move the choice cursor by `delta`, skipping disabled rows and wrapping.
781
+ * No-op outside a choice, and a zero `delta` is a no-op (no cursor move, no
782
+ * event). A move that steps over disabled rows fires exactly one
783
+ * selection-changed event, for the row it lands on. */
784
+ moveSelection(delta: number): void;
785
+ /** Highlight a choice by absolute position (e.g. pointer hover). No wrap;
786
+ * a disabled row is skipped (the cursor stays put). */
787
+ selectAt(position: number): void;
788
+ /** The next enabled choice position from `from` in direction `dir` (±1),
789
+ * wrapping. Returns `from` when no other enabled row exists (so a single
790
+ * enabled option among disabled ones never moves). */
791
+ private nextEnabled;
792
+ /** Fire onSelectionChanged for the currently-highlighted choice (keyboard nav
793
+ * and pointer hover both land here — one canonical selection event). */
794
+ private emitSelectionChanged;
795
+ /** Commit the highlighted choice. */
796
+ confirm(): void;
797
+ /** Commit by original option index (e.g. a direct pointer hit, or the
798
+ * timed-choice recipe firing its default). Refuses an unknown or disabled
799
+ * option. */
800
+ choose(optionIndex: number): void;
801
+ /** Commit by display position (a pointer hit on a row). The position-keyed
802
+ * counterpart to {@link choose}; refuses an out-of-range or disabled row.
803
+ * Used by the pointer binding and the presenter pointer-commit seam. */
804
+ confirmAt(position: number): void;
805
+ /**
806
+ * The single choice-commit authority: every commit path routes here after
807
+ * translating its argument to a display position. It guards (paused / not
808
+ * choosing / already confirming / a missing or disabled row), latches against a
809
+ * double-commit, fires `onChoiceMade`, and steps the runner. The latch (not the
810
+ * runner) is what guarantees a single commit: `mode` stays "choosing" while the
811
+ * runner awaits the option's blocking commands, so without it a second confirm
812
+ * would emit a duplicate `onChoiceMade`.
813
+ */
814
+ private commit;
815
+ /** Toggle hold-to-fast-forward; the text channel scales its reveal rate. */
816
+ setFastForward(on: boolean): void;
817
+ /**
818
+ * Default auto-advance: lines without their own `autoAdvanceMs` advance `ms`
819
+ * after they finish revealing; `null` turns it off (manual advance). A
820
+ * per-line `autoAdvanceMs` always overrides this. Toggling it while a line is
821
+ * already sitting revealed arms/clears its timer immediately.
822
+ */
823
+ setAutoAdvance(ms: number | null): void;
824
+ private handleSay;
825
+ private handleChoice;
826
+ private handleCommand;
827
+ private handleEnd;
828
+ private handleRevealComplete;
829
+ /**
830
+ * Fan one reveal beat out — the per-line typewriter stream. Extras (Voice /
831
+ * CameraEffects / a typewriter-SFX channel) see the WHOLE stream via
832
+ * `revealBeat?`. A `tick` then reaches the host's `onRevealTick` callback; a
833
+ * `marker` reaches the avatar channel (which interprets `[expression=…/]`
834
+ * itself — the Session name-matches nothing) and the host's `onRevealMarker`.
835
+ */
836
+ private handleRevealBeat;
837
+ /**
838
+ * Fire the current line's commands matching `at`, via the runner's command
839
+ * pipeline (so `set`/blocking behave identically). While a blocking one is
840
+ * awaited, `lineBlocked` gates input so the player can't advance through it.
841
+ * `mode` overrides the runner's run mode (skip() fires the displayed line's
842
+ * batches in skip mode).
843
+ */
844
+ private fireLineCommands;
845
+ private speakerName;
846
+ private speakerView;
847
+ }
848
+
849
+ /**
850
+ * Adapter-level presenter contracts. The headless {@link DialogueChannels}
851
+ * (core) describe *what* a chrome / choice presenter does; these add the YAGE
852
+ * lifecycle (`mount(scene)` / `dispose()`) the host drives. Concrete renderer
853
+ * presenters ({@link DialogueChrome}, {@link ChoiceListPresenter}) implement
854
+ * these; a ui-react / DOM chrome you write later implements the same shape.
855
+ */
856
+
857
+ /** A dev-facing diagnostics sink — the controller wires this to the engine
858
+ * Logger (the same seam as the session's `onError`), so a presenter-level
859
+ * warning (e.g. a missing actor) routes there instead of `console.warn`. */
860
+ type DiagnosticSink = (message: string) => void;
861
+ /** YAGE lifecycle shared by the renderer-based presenters. */
862
+ interface Mountable {
863
+ mount(scene: Scene): void;
864
+ dispose(): void;
865
+ /** Optional: receive a diagnostics sink. The controller injects one at
866
+ * mount so a presenter can report dev-facing issues (a missing actor) through
867
+ * the engine Logger. Presenters with nothing to report omit it. */
868
+ setDiagnostics?(warn: DiagnosticSink): void;
869
+ }
870
+ /** Frame / nameplate / continue caret. `setVisible` (now part of
871
+ * {@link ChromeChannel}) lets a composite chrome show/hide a whole variant
872
+ * (e.g. hide the box while a bubble line plays). */
873
+ interface ChromePresenter extends ChromeChannel, Mountable {
874
+ }
875
+ /** The choice list / wheel / panel. Optionally hit-tests pointer coords so a
876
+ * pointer binding can hover/click rows ({@link PointerChoiceTarget}). The
877
+ * hit-test (and `pointerSpace`) are read in screen space by default; a
878
+ * world-anchored presenter (e.g. a bubble) sets `pointerSpace: "world"`. */
879
+ interface ChoicePresenter extends ChoiceChannel, Mountable {
880
+ choiceAtPoint?(x: number, y: number): number | undefined;
881
+ /** Coordinate space `choiceAtPoint` expects. Default "screen". */
882
+ readonly pointerSpace?: "screen" | "world";
883
+ }
884
+ /**
885
+ * Body-text presenter: the headless {@link TextChannel} (reveal timing) plus the
886
+ * YAGE lifecycle the host drives.
887
+ *
888
+ * This contract lives in this pixi-free adapter module — NOT on the concrete,
889
+ * renderer-backed `DialogueTextView` — so the headless root entry (`"."`) can
890
+ * reach it through the `DialogueController` without transitively importing
891
+ * `@yagejs/renderer`. The renderer-backed views only *implement* this shape.
892
+ */
893
+ interface TextPresenter extends TextChannel, Mountable {
894
+ }
895
+
896
+ /**
897
+ * The avatar layer is deliberately decoupled from the box: the controller only
898
+ * ever talks to this interface, so a speaker can be shown as a portrait beside
899
+ * the box, as a full figure already standing in the scene, or not at all —
900
+ * without the dialogue runtime knowing which. Implementations live alongside
901
+ * (PortraitPresenter, SceneFigurePresenter); swap freely or compose your own.
902
+ */
903
+
904
+ /**
905
+ * The adapter-level avatar presenter: the headless {@link AvatarChannel}
906
+ * (setSpeaker / setExpression / setSpeaking / marker / update) plus the YAGE
907
+ * lifecycle the host drives (mount / dispose).
908
+ */
909
+ interface AvatarPresenter extends AvatarChannel {
910
+ /** Called once when the controller mounts. */
911
+ mount(scene: Scene): void;
912
+ dispose(): void;
913
+ }
914
+ /** No-op presenter — the default when a script has no avatars. */
915
+ declare class NullAvatarPresenter implements AvatarPresenter {
916
+ mount(): void;
917
+ setSpeaker(): void;
918
+ setExpression(): void;
919
+ setSpeaking(): void;
920
+ update(): void;
921
+ dispose(): void;
922
+ }
923
+
924
+ /**
925
+ * Input is externalised so it's obviously *optional* and obviously *swappable*.
926
+ * A `DialogueSession` exposes an input-agnostic API (`advance / moveSelection /
927
+ * setFastForward`); an {@link InputBinding} is whatever maps a device onto it.
928
+ * The default {@link KeyboardInputBinding} polls the YAGE `InputManager` action
929
+ * map. An ambient (auto-advancing) conversation simply attaches no binding; a
930
+ * touch/gamepad/pointer binding is a parallel implementation of this interface.
931
+ */
932
+
933
+ interface DialogueActions {
934
+ /** Tap → reveal-all if typing, else next line / confirm choice. */
935
+ readonly advance: readonly string[];
936
+ /** Hold → fast-forward the typewriter. */
937
+ readonly speed: readonly string[];
938
+ readonly up: readonly string[];
939
+ readonly down: readonly string[];
940
+ /** Tap → skip the current section to the next choice/end. Unbound by default. */
941
+ readonly skip?: readonly string[];
942
+ }
943
+ declare const DEFAULT_ACTIONS: DialogueActions;
944
+ /** Keyboard actions with skip bound (the game maps `skip` → KeyX in main.ts). */
945
+ declare const FULL_ACTIONS: DialogueActions;
946
+ interface InputBinding {
947
+ /**
948
+ * Wire a device to a session. Called by the host once both exist. A binding
949
+ * has a single owner: re-binding re-targets it (implementations must release
950
+ * any resources held for the previous bind) — don't share one instance
951
+ * between two live controllers.
952
+ */
953
+ bind(input: InputManager, session: DialogueSession): void;
954
+ /** Poll the device and drive the session. Called once per frame by the host. */
955
+ poll(): void;
956
+ /** Optional teardown (e.g. unsubscribe pointer listeners). */
957
+ dispose?(): void;
958
+ }
959
+ /**
960
+ * A presenter that can resolve a pointer point to a choice position — lets a
961
+ * pointer binding pick/hover choices without owning their geometry. Coords are
962
+ * in `pointerSpace` ("screen" default; a bubble/world list uses "world").
963
+ */
964
+ interface PointerChoiceTarget {
965
+ /** Position of the choice row under this point, or undefined. Omit for no
966
+ * pointer hit-testing. */
967
+ choiceAtPoint?(x: number, y: number): number | undefined;
968
+ readonly pointerSpace?: "screen" | "world";
969
+ }
970
+ /** Fan a single session out to several device bindings (keyboard + pointer …). */
971
+ declare class CompositeInputBinding implements InputBinding {
972
+ private readonly bindings;
973
+ constructor(bindings: readonly InputBinding[]);
974
+ bind(input: InputManager, session: DialogueSession): void;
975
+ poll(): void;
976
+ dispose(): void;
977
+ }
978
+ /** Keyboard/gamepad action-map binding (the default). */
979
+ declare class KeyboardInputBinding implements InputBinding {
980
+ private readonly actions;
981
+ private readonly skipHoldMs;
982
+ private input?;
983
+ private session?;
984
+ /** Latch so a held skip fires once per hold, not every frame past threshold. */
985
+ private skipFired;
986
+ /**
987
+ * @param skipHoldMs Hold the `skip` action this long before it fires (the
988
+ * classic "hold to skip" confirm). `0` (default) fires on press.
989
+ */
990
+ constructor(actions?: DialogueActions, skipHoldMs?: number);
991
+ bind(input: InputManager, session: DialogueSession): void;
992
+ poll(): void;
993
+ /** Fire skip once the action has been held `skipHoldMs` (hold-to-confirm),
994
+ * re-arming only after it's released. */
995
+ private pollSkip;
996
+ }
997
+ /**
998
+ * Mouse/touch binding. A tap during a line advances (reveal-all, then next);
999
+ * a tap on a choice row picks it, and hover highlights it — provided a
1000
+ * {@link PointerChoiceTarget} is supplied so the binding can hit-test rows.
1001
+ * Works for both mouse and touch since it rides the unified pointer stream.
1002
+ */
1003
+ declare class PointerInputBinding implements InputBinding {
1004
+ private input?;
1005
+ private session?;
1006
+ private unsub;
1007
+ /** A primary-button press happened since the last poll (consumed in poll). */
1008
+ private clicked;
1009
+ private readonly choices;
1010
+ /** Pointer position at the last hover hit-test, so an unmoved pointer
1011
+ * doesn't re-run the hit-test every frame. */
1012
+ private lastX;
1013
+ private lastY;
1014
+ /** Whether the previous poll saw a choice up (a fresh choice set must be
1015
+ * hit-tested even under a stationary pointer). */
1016
+ private wasChoosing;
1017
+ constructor(choices?: PointerChoiceTarget);
1018
+ bind(input: InputManager, session: DialogueSession): void;
1019
+ /** Pointer position in the choice presenter's coordinate space. */
1020
+ private pointer;
1021
+ poll(): void;
1022
+ dispose(): void;
1023
+ }
1024
+ /**
1025
+ * The full control set in one binding: keyboard/gamepad (advance / fast-forward
1026
+ * hold / choice nav / skip) **and** mouse/touch (tap to advance, tap/hover
1027
+ * choices). Pass a scene's choice presenter so the pointer can hit-test rows;
1028
+ * `skipHoldMs` adds the classic "hold to skip" confirm.
1029
+ */
1030
+ declare function fullControls(choices?: PointerChoiceTarget, options?: {
1031
+ actions?: DialogueActions;
1032
+ skipHoldMs?: number;
1033
+ }): InputBinding;
1034
+
1035
+ /**
1036
+ * DialogueController — the thin YAGE host. It owns no dialogue logic; it just:
1037
+ *
1038
+ * • mounts the presenters onto the scene,
1039
+ * • builds a headless {@link DialogueSession} over them,
1040
+ * • forwards the session's observation callbacks to engine events,
1041
+ * • attaches an {@link InputBinding} (keyboard by default) and pumps it,
1042
+ * • pumps `session.update(dt)` each frame.
1043
+ *
1044
+ * All the sequencing, reveal-gating, choice book-keeping, and i18n live in the
1045
+ * session (engine-agnostic). The presenter bundle usually comes from a factory
1046
+ * (`createBoxDialogue(theme)`), spread-and-overridden as needed:
1047
+ *
1048
+ * host.add(new DialogueController({ ...createBoxDialogue(theme), avatar, storage }));
1049
+ */
1050
+
1051
+ /** The presenter trio a factory assembles (see `createBoxDialogue`).
1052
+ * Optional fields are `T | undefined` so factories and games can assign
1053
+ * possibly-undefined theme values directly (exactOptionalPropertyTypes). */
1054
+ interface DialogueBundle {
1055
+ readonly chrome: ChromePresenter;
1056
+ readonly text: TextPresenter;
1057
+ readonly choices: ChoicePresenter;
1058
+ readonly avatar?: AvatarPresenter | undefined;
1059
+ /** Hold-to-fast-forward multiplier. Default 4. */
1060
+ readonly skipMultiplier?: number | undefined;
1061
+ }
1062
+ interface DialogueControllerOptions<TStorage extends VariableStorage = VariableStorage> extends DialogueBundle {
1063
+ readonly i18n?: I18nAdapter | undefined;
1064
+ /**
1065
+ * The variable storage installed for every `play()`. Persists across
1066
+ * plays. Omit for a zero-config `MemoryVariableStorage`; supply your own (or
1067
+ * `compose(cells(...), new MemoryVariableStorage())`) to bridge game state. A
1068
+ * per-`play()` `overrides.storage` replaces it for that conversation.
1069
+ */
1070
+ readonly storage?: TStorage | undefined;
1071
+ /** Argument-capable read functions (`has_item("key")`) shared across plays. */
1072
+ readonly functions?: Readonly<Record<string, DialogueFunction>> | undefined;
1073
+ /** Command handlers (`type` → handler) shared across plays; per-`play()`
1074
+ * `overrides.commands` merge on top (call site wins). */
1075
+ readonly commands?: Readonly<Record<string, CommandHandler>> | undefined;
1076
+ /** Catch-all for command types with no explicit handler. */
1077
+ readonly fallbackCommand?: CommandHandler | undefined;
1078
+ /** Device → session binding. Omit for the default keyboard binding. */
1079
+ readonly input?: InputBinding;
1080
+ /**
1081
+ * Extra channels registered on the session at mount (Voice / Shop /
1082
+ * CameraEffects / History) — the open-ended companion to the presenter trio.
1083
+ * Each is wired via {@link DialogueController.addChannel}; one that also
1084
+ * implements {@link Mountable} (it needs the scene) is mounted in `onAdd` and
1085
+ * disposed in `onDestroy`. A factory bundle can pre-wire e.g. a voice channel
1086
+ * here; a game can also add one live with {@link DialogueController.addChannel}.
1087
+ */
1088
+ readonly channels?: readonly DialogueExtraChannel[] | undefined;
1089
+ /**
1090
+ * Per-grapheme typewriter tick — a direct callback (NOT an entity event; it
1091
+ * fires hundreds of times per line). `index` is the raw grapheme index revealed
1092
+ * (whitespace included — filter if you only want a blip on visible glyphs).
1093
+ * Wire a typewriter SFX here. Inline `[name k=v/]` markers, by contrast, come
1094
+ * through {@link DialogueRevealMarkerEvent} on the entity bus.
1095
+ */
1096
+ readonly onRevealTick?: ((index: number) => void) | undefined;
1097
+ /** Called once when a conversation ends (in addition to the scene event). */
1098
+ readonly onEnded?: () => void;
1099
+ }
1100
+ declare class DialogueController<TStorage extends VariableStorage = VariableStorage> extends Component {
1101
+ private readonly opts;
1102
+ private readonly input;
1103
+ private readonly binding;
1104
+ private session;
1105
+ /** Captured at onAdd (the scene is gone by the time a stale play() arrives). */
1106
+ private logger;
1107
+ /** Set by onDestroy — the presenters are disposed, so play() must refuse. */
1108
+ private destroyed;
1109
+ /** Input focus. When false, `update()` keeps pumping the session (an
1110
+ * ambient conversation stays alive) but the binding is NOT polled, so this
1111
+ * instance doesn't consume device input. NOT `Component.enabled` (which would
1112
+ * also freeze the session). */
1113
+ private inputEnabled;
1114
+ /** Pause. Mirrors the session's pause so the binding poll is also gated
1115
+ * while frozen — a paused conversation neither updates nor consumes input.
1116
+ * Also the source of truth re-applied to the session in `onAdd` when a host
1117
+ * set it before the component was added (the session didn't exist yet). */
1118
+ private paused;
1119
+ /** Hidden. Mirrors the session's hide so a `setHidden` issued before the
1120
+ * component was added isn't lost — it's re-applied once the session exists. */
1121
+ private hidden;
1122
+ /** Disposers for every registered extra channel (ctor `channels` + live
1123
+ * `addChannel`). `onDestroy` runs them all — each idempotent — to unregister
1124
+ * and dispose (unmounting the Mountable ones). */
1125
+ private readonly channelDisposers;
1126
+ constructor(opts: DialogueControllerOptions<TStorage>);
1127
+ onAdd(): void;
1128
+ onDestroy(): void;
1129
+ /**
1130
+ * Begin a conversation. `play(script)` is **content-only** — storage,
1131
+ * functions, and commands are installed on the controller. `overrides` layers
1132
+ * per-conversation specifics on top (a scoped `storage`, extra
1133
+ * `functions`/`commands`). Returns a {@link DialogueHandle} for live `setVar` /
1134
+ * `getVars`, or `undefined` if the controller was removed.
1135
+ */
1136
+ play<S extends DialogueScript>(script: S, overrides?: DialoguePlayOptions): DialogueHandle<VarsOf<S>> | undefined;
1137
+ isActive(): boolean;
1138
+ /** Abandon the current conversation and reset to idle. */
1139
+ stop(): void;
1140
+ /**
1141
+ * Register an extra channel live — Voice / Shop / CameraEffects / History.
1142
+ * Mounts it if it needs the scene ({@link Mountable}), hands it to the session
1143
+ * (where it joins the cross-cutting stream and can gate auto-advance), and
1144
+ * returns a disposer that unregisters + disposes it. The `channels` ctor option
1145
+ * registers a bundle the same way at mount. Returns a no-op disposer if the
1146
+ * controller was destroyed; **throws** if called before the component is added
1147
+ * to an entity (use the `channels` ctor option to pre-wire a channel) — mirrors
1148
+ * {@link play}.
1149
+ */
1150
+ addChannel(ch: DialogueExtraChannel): () => void;
1151
+ /** Fast-forward the current section to the next choice or the end. */
1152
+ skip(): void;
1153
+ /**
1154
+ * Auto-advance lines after they finish revealing (`ms`), or `null` to disable
1155
+ * (manual advance). A per-line `autoAdvanceMs` still overrides this. Toggle it
1156
+ * live for a VN-style "auto" control.
1157
+ */
1158
+ setAutoAdvance(ms: number | null): void;
1159
+ /**
1160
+ * Hide or show the whole dialogue UI without ending the conversation —
1161
+ * for a cutscene takeover (`setHidden(true)` while the camera pans, then
1162
+ * `setHidden(false)` to restore the exact line + caret). Purely visual; the
1163
+ * conversation keeps its state. **Persistent**: it survives `stop()`/`play()`,
1164
+ * so a host that hides and forgets to unhide stays hidden.
1165
+ */
1166
+ setHidden(hidden: boolean): void;
1167
+ /**
1168
+ * Freeze or resume the conversation — a pause menu. While paused the
1169
+ * reveal, auto-advance, caret blink, and avatar anim all halt, input is inert,
1170
+ * and no state is lost (an in-flight blocking command keeps running). Also
1171
+ * gates this controller's input binding so a frozen conversation consumes no
1172
+ * device input. Does NOT block host-driven `handle.setVar` / storage writes.
1173
+ */
1174
+ setPaused(paused: boolean): void;
1175
+ /**
1176
+ * Set whether this controller consumes device input — the focus seam for
1177
+ * the multi-instance story. `setInputEnabled(false)` keeps the conversation
1178
+ * fully alive (it still updates, reveals, auto-advances) but stops polling its
1179
+ * binding, so an ambient conversation doesn't steal the advance key. Switch
1180
+ * focus between two conversations with `a.setInputEnabled(true);
1181
+ * b.setInputEnabled(false)`. (YAGE input is non-consuming, so two *enabled*
1182
+ * controllers both advance on one press — focus is the game's policy.)
1183
+ */
1184
+ setInputEnabled(enabled: boolean): void;
1185
+ /**
1186
+ * Primary action, host-driven (the input-agnostic seam): while saying,
1187
+ * reveal-all if still typing else advance to the next line; while choosing,
1188
+ * confirm the highlighted option. Lets a host (cutscene script, custom input,
1189
+ * or a test) drive the conversation without synthesising device input — the
1190
+ * same call the default {@link InputBinding} makes.
1191
+ */
1192
+ advance(): void;
1193
+ /** Move the choice cursor by `delta` (wraps). No-op outside a choice. */
1194
+ moveSelection(delta: number): void;
1195
+ /** Commit a choice by its original option index. No-op outside a choice. */
1196
+ choose(optionIndex: number): void;
1197
+ /** True while a choice is being presented. */
1198
+ isChoosing(): boolean;
1199
+ /** Side-effect-free lookahead of the lines a node would show. */
1200
+ preview(nodeId: string): PreviewedLine[];
1201
+ update(dt: number): void;
1202
+ }
1203
+
1204
+ export { type AvatarChannel as A, type ChoiceChannel as C, type DialogueExtraChannel as D, FULL_ACTIONS as F, type I18nAdapter as I, KeyboardInputBinding as K, LineReveal as L, type Mountable as M, NullAvatarPresenter as N, type PointerChoiceTarget as P, type RevealBeat as R, type SpeakerView as S, type TextChannel as T, type VarsOf as V, type ChoiceContext as a, type ChromeChannel as b, CompositeInputBinding as c, DEFAULT_ACTIONS as d, type DialogueActions as e, type DialogueBundle as f, type DialogueChannels as g, DialogueController as h, type DialogueControllerOptions as i, DialogueSession as j, type DialogueSessionOptions as k, IdentityI18n as l, type InputBinding as m, PointerInputBinding as n, type PresentedChoice as o, type PresentedLine as p, type PreviewedLine as q, type TypedScript as r, defineScript as s, fullControls as t, interpolate as u, type TextPresenter as v, type DiagnosticSink as w, type ChromePresenter as x, type ChoicePresenter as y, type AvatarPresenter as z };