@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,1817 @@
1
+ import { Scene, Entity, Component } from '@yagejs/core';
2
+ import { v as TextPresenter, p as PresentedLine, R as RevealBeat, w as DiagnosticSink, x as ChromePresenter, y as ChoicePresenter, o as PresentedChoice, a as ChoiceContext, z as AvatarPresenter, f as DialogueBundle } from './DialogueController-Cs5IUc-u.js';
3
+ export { M as Mountable, N as NullAvatarPresenter } from './DialogueController-Cs5IUc-u.js';
4
+ import { P as ParsedText, d as SpeakerDef, M as MarkerToken } from './types-DSbBSlh7.js';
5
+ import { TextureInput, LayerDef } from '@yagejs/renderer';
6
+ import '@yagejs/input';
7
+
8
+ /**
9
+ * Font plumbing shared by the renderer-backed presenters: every chrome / choice
10
+ * presenter renders incidental text (nameplates, choice labels) the same way — an
11
+ * optional baked bitmap font wins, else the canvas family + resolution. One
12
+ * {@link FontConfig} triplet + one {@link makeTextOptions} builder keep the
13
+ * presenter configs and their `TextComponent` construction in lockstep.
14
+ *
15
+ * (Choice-row helpers — labelling, disabled-row dimming, highlight — live in
16
+ * `./choiceRow.js`.)
17
+ */
18
+
19
+ /** The font triplet every presenter config carries (canvas by default). */
20
+ interface FontConfig {
21
+ /** Baked bitmap-font name (OPT-IN crisp-pixel path). Omit for canvas text. */
22
+ readonly bitmapFont?: string | undefined;
23
+ /** Canvas font family (used when {@link bitmapFont} is omitted). */
24
+ readonly fontFamily?: string | undefined;
25
+ /** Canvas render resolution (used when not bitmap). */
26
+ readonly resolution?: number | undefined;
27
+ }
28
+
29
+ /**
30
+ * DialogueTextView — renders one parsed line as a single {@link SplitTextComponent}
31
+ * (the engine wrapper for Pixi `SplitText`/`SplitBitmapText`) on a screen-space
32
+ * layer, revealing it glyph-by-glyph (typewriter), honouring per-run colour/
33
+ * bold/italic, and driving animated effects.
34
+ *
35
+ * Reveal *timing* — the grapheme cursor, inline `[pause=ms/]`, per-run/line
36
+ * `[speed]`, the hold multiplier, and fired-once completion — is owned by the
37
+ * headless {@link LineReveal} clock (pixi-free, reusable by a DOM presenter).
38
+ * This view keeps only the pixi-`SplitText` concerns: mapping the clock's
39
+ * grapheme cursor onto glyph visibility and fanning per-glyph styles out.
40
+ *
41
+ * Why one split per LINE (not per word): SplitText does its own tokenize /
42
+ * measure / wrap / glyph-split, so we delegate layout to it instead of hand-
43
+ * rolling it. We then reach into the per-glyph `chars` for everything rich:
44
+ * - reveal → toggle `chars[i].visible` (no re-layout; split is done once)
45
+ * - colour → `chars[i].tint` per run (independent per glyph)
46
+ * - bold/italic → reassign that glyph's `style` to a baked variant atlas
47
+ * (the chars SHARE one style object, so we assign a fresh one rather than
48
+ * mutate — mutating would restyle the whole line)
49
+ * - effects → per-glyph `position`/`scale`/`tint` (wave now ripples per letter)
50
+ *
51
+ * Pixi nests the split `root → line → word → char`, so a glyph's position in the
52
+ * split's own space is the sum up its parent chain ({@link localInSplit}).
53
+ *
54
+ * All reveal bookkeeping counts GRAPHEMES (`splitGraphemes` — the same
55
+ * segmentation SplitText uses to make one glyph node per user-perceived
56
+ * character), so emoji / ZWJ sequences / combining marks stay aligned between
57
+ * the cursor, `[pause]` offsets, per-glyph styles, and the rendered glyphs.
58
+ * `SplitText.chars` additionally drops whitespace, but our runs / reveal
59
+ * cursor / `[pause]` offsets count it, so we map "global grapheme index (with
60
+ * spaces) → non-space glyph index" via a prefix table.
61
+ *
62
+ * This file is the only renderer-coupled part of text rendering; it takes the
63
+ * scene + a font/layout config so nothing here is game-specific.
64
+ */
65
+
66
+ interface DialogueTextConfig extends FontConfig {
67
+ /** Font size in px. */
68
+ readonly textSize: number;
69
+ /** Vertical advance between wrapped lines, in px. */
70
+ readonly lineHeight: number;
71
+ /** Colour for runs that don't override it (0xRRGGBB). */
72
+ readonly textColor: number;
73
+ /** Base reveal rate (graphemes/second). Scaled by per-run + hold speed. */
74
+ readonly charsPerSec: number;
75
+ /** Render layer name (screen-space). */
76
+ readonly layer: string;
77
+ /** Resting text region (screen px). Bubbles override per line via `setBox`. */
78
+ readonly box?: {
79
+ readonly x: number;
80
+ readonly y: number;
81
+ readonly width: number;
82
+ };
83
+ }
84
+ declare class DialogueTextView implements TextPresenter {
85
+ private readonly cfg;
86
+ private scene?;
87
+ private line?;
88
+ private parsed?;
89
+ private boxX;
90
+ private boxY;
91
+ private wrapWidth;
92
+ /** Top-left the split container sits at (box origin). */
93
+ private layoutOriginX;
94
+ private layoutOriginY;
95
+ /** Optional per-frame origin (a moving NPC's head) for diegetic bubbles. */
96
+ private originProvider?;
97
+ /** `nonSpacePrefix[k]` = count of non-space graphemes among the first `k`. */
98
+ private nonSpacePrefix;
99
+ /** Non-space glyphs currently visible. */
100
+ private shownCount;
101
+ /** Headless reveal clock — owns the grapheme cursor, `[pause]` arming, hold +
102
+ * per-line + per-run speed, and the fired-once completion. This view keeps
103
+ * only the pixi-`SplitText` concerns (glyph prefix mapping + per-glyph style
104
+ * fan-out) and maps the clock's grapheme cursor onto them. */
105
+ private readonly reveal;
106
+ /** Elapsed ms for animated per-glyph EFFECTS (wave/shake/…) — distinct from
107
+ * the reveal cursor, which LineReveal owns. */
108
+ private elapsedMs;
109
+ /** Scratch for {@link evaluateEffect} — one object reused across all glyphs. */
110
+ private readonly effectScratch;
111
+ /** Reveal-completed listener, registered by the Session through
112
+ * {@link setRevealListener} (a private seam, not a public field, so a
113
+ * game can't clobber the session's wiring). */
114
+ private revealListener?;
115
+ /** Reveal-beat listener (ticks + inline markers), registered by the Session
116
+ * through {@link setBeatListener} — same private-seam discipline. */
117
+ private beatListener?;
118
+ /** Master visibility gate ({@link setVisible}); hides the line WITHOUT
119
+ * clearing it, so a hide/show round-trip resumes mid-typewriter. */
120
+ private hidden;
121
+ constructor(cfg: DialogueTextConfig);
122
+ /** Attach to a scene (host lifecycle). Must run before the first `present`. */
123
+ mount(scene: Scene): void;
124
+ /** Top-left of the text region, in screen px, plus the wrap width. */
125
+ setBox(x: number, y: number, width: number): void;
126
+ /**
127
+ * Make the text track a per-frame origin (a diegetic bubble following an
128
+ * NPC). The provider returns the top-left the laid-out box should sit at;
129
+ * pass `undefined` to pin the text (the default box-dialogue behaviour).
130
+ */
131
+ setOrigin(provider: (() => {
132
+ x: number;
133
+ y: number;
134
+ }) | undefined): void;
135
+ /** TextChannel entry point: render + reveal a fully-resolved line. */
136
+ present(line: PresentedLine): void;
137
+ /** TextChannel: reveal everything now. */
138
+ completeReveal(): void;
139
+ /** TextChannel: true once the line is fully revealed. */
140
+ isRevealComplete(): boolean;
141
+ /** Hold-to-speed multiplier (1 = normal, e.g. 3 while the skip key is held). */
142
+ setSpeedMultiplier(m: number): void;
143
+ isRevealing(): boolean;
144
+ /**
145
+ * Show or hide the body text WITHOUT disturbing reveal progress. Toggles
146
+ * the laid-out split container's visibility; the per-glyph reveal cursor,
147
+ * timers, and styling are untouched, so a cutscene can hide mid-typewriter and
148
+ * show again to resume exactly where it left off.
149
+ */
150
+ setVisible(visible: boolean): void;
151
+ /** Register the reveal-completed listener. Session-owned (a private seam, not a
152
+ * public field a game could clobber); pass `undefined` to clear. */
153
+ setRevealListener(listener: (() => void) | undefined): void;
154
+ /** Register the reveal-beat listener (ticks + inline markers). Session-owned;
155
+ * pass `undefined` to clear. The clock emits in char order as glyphs reveal. */
156
+ setBeatListener(listener: ((beat: RevealBeat) => void) | undefined): void;
157
+ /** Build the split for a parsed line and start revealing. */
158
+ show(parsed: ParsedText, lineSpeed?: number): void;
159
+ /** Apply the master visibility gate to the laid-out line — toggles the split
160
+ * container, leaving the per-glyph reveal state intact. */
161
+ private applyHidden;
162
+ /** Reveal everything immediately (jump-to-end on a click/tap). */
163
+ skipToEnd(): void;
164
+ update(dt: number): void;
165
+ clear(): void;
166
+ /** Per-line teardown (also the first step of `show()`). The reveal clock is
167
+ * re-armed by the next `show()` via {@link LineReveal.begin}, so there is no
168
+ * reveal state to reset here. */
169
+ private clearLine;
170
+ /** Permanent teardown. (No measurer nodes to free — SplitText owns layout.) */
171
+ dispose(): void;
172
+ private buildLine;
173
+ /**
174
+ * One grapheme-segmentation pass per line (build-time only — nothing
175
+ * re-segments per frame), producing both reveal tables:
176
+ * - `nonSpacePrefix`: grapheme cursor → count of non-space glyphs shown
177
+ * - returned styles: the run-style for each NON-SPACE glyph, in reading
178
+ * order (1:1 with SplitText's `chars`, which drops whitespace)
179
+ * Segmenting per run matches how markup.ts counted `length`/`atChar`, so
180
+ * the cursor, pauses, and styles all share one basis.
181
+ */
182
+ private buildRevealTables;
183
+ /**
184
+ * Apply a run's bold/italic to one glyph. On the bitmap path we must NOT swap
185
+ * to the baked variant atlas — each atlas has its own `baseLineOffset`, which
186
+ * lifts/drops a swapped glyph out of line with the regular runs. So we keep the
187
+ * regular-atlas glyph (baseline intact) and synthesise:
188
+ * - italic → shear it in place (`skew.x`)
189
+ * - bold → overlay a 1px-offset copy as a CHILD, so it rides the parent's
190
+ * reveal/visibility, tint cascade, skew, and per-frame effects for
191
+ * free (no separate bookkeeping).
192
+ * On the canvas path, real bold/italic of the same family is baseline-safe, so
193
+ * we just set the style flags.
194
+ */
195
+ private applyWeight;
196
+ private applyReveal;
197
+ /**
198
+ * Per-frame placement: follow a moving origin (bubble mode) by moving the
199
+ * split container's `Transform` — every glyph inherits it — then apply
200
+ * per-glyph animated effects on top, in the glyph's own (parent) space.
201
+ * Only effect-bearing glyphs are walked; a static pinned line costs nothing.
202
+ */
203
+ private reposition;
204
+ /** Options for the per-line split: regular atlas, wrap to the box, white fill
205
+ * (per-glyph `tint` carries the real colour). */
206
+ private lineSplitOptions;
207
+ }
208
+
209
+ /**
210
+ * The ONE shared "where does this speaker's bubble go" resolver.
211
+ *
212
+ * The three bubble presenters (`BubbleChrome`, `BubbleTextView`,
213
+ * `BubbleChoicePresenter`) all need the same answer for "anchor the bubble for
214
+ * speaker X", including the failure cases: a
215
+ * despawned NPC, a typo'd speaker, or a speakerless narrator line routed to a
216
+ * pure-bubble bundle. Three independent copies of that logic is exactly the bug
217
+ * class — so it lives here once. (A future layout owner can absorb this.)
218
+ *
219
+ * Policy:
220
+ * - A **live actor** wins: use its head anchor, and refresh the caches.
221
+ * - A **missing declared speaker** (despawn / typo) falls back to that
222
+ * speaker's last-known position, else the most recent any-speaker anchor,
223
+ * else a configurable anchor — and warns (at most once per speaker id per
224
+ * resolver instance) through the diagnostics sink (→ engine Logger), never
225
+ * `console.warn`.
226
+ * - A **speakerless narrator** line (no id) uses the same fallback chain but
227
+ * never warns — it is authored intent, not a failure.
228
+ *
229
+ * The bubble stays VISIBLE in every case — anchored to a sane fallback rather
230
+ * than hidden — so a line is always readable somewhere with its continue caret.
231
+ */
232
+
233
+ interface AnchorPoint {
234
+ readonly x: number;
235
+ readonly y: number;
236
+ }
237
+ /** Resolves a speaker id to a world anchor, with last-known caching + a
238
+ * fallback for missing/absent actors. Each bubble presenter owns its own
239
+ * instance; the position caches converge frame-to-frame (every resolver sees
240
+ * the same live actors), but the missing-actor warning dedups *per instance* —
241
+ * a missing speaker warns at most once per presenter that anchors it (so up to
242
+ * ~2–3× across a bubble bundle), not once globally. A future single layout
243
+ * owner could collapse these into one shared instance. */
244
+ declare class BubbleAnchorResolver {
245
+ private readonly fallback;
246
+ private readonly lastKnown;
247
+ private lastAnchor;
248
+ private readonly warned;
249
+ private warn;
250
+ /**
251
+ * @param fallback Ultimate anchor when nothing better is known (a speaker
252
+ * never seen and no prior bubble). Defaults to the world origin; a
253
+ * pure-bubble bundle that shows narrator lines should point this at its
254
+ * camera centre so a speakerless line lands on screen.
255
+ */
256
+ constructor(fallback?: () => AnchorPoint);
257
+ /** Wire the diagnostics sink (the controller injects the engine-Logger one). */
258
+ setDiagnostics(warn: DiagnosticSink): void;
259
+ /**
260
+ * World anchor for `speakerId`. Live actor → its anchor (caches refreshed).
261
+ * Missing → last-known for that speaker, else the most recent any-speaker
262
+ * anchor, else {@link fallback}. Warns at most once per declared speaker id
263
+ * (per resolver instance); a speakerless line never warns.
264
+ */
265
+ resolve(scene: Scene, speakerId: string | undefined): AnchorPoint;
266
+ }
267
+
268
+ interface BubbleSize {
269
+ readonly width: number;
270
+ readonly height: number;
271
+ }
272
+
273
+ /**
274
+ * BubbleLayout — the single per-line geometry owner for the speech-bubble
275
+ * coordinate model (the bubble half of the "layout owner"). One instance is
276
+ * injected into `BubbleChrome`, `BubbleTextView`, and `BubbleChoicePresenter` so
277
+ * they can no longer drift: the bubble outer size is measured **once** per line
278
+ * (memoized — the session always calls `chrome.present` then `text.present` with
279
+ * the same line object, so the second read is free), the missing-actor anchor
280
+ * policy lives in ONE {@link BubbleAnchorResolver}, and the anchor→inner-top-left
281
+ * origin formula exists once.
282
+ *
283
+ * It owns the bubble *geometry* (sizing inputs + padding/offsetY); the
284
+ * presenters keep only their drawing config (colours, tail, caret).
285
+ */
286
+
287
+ interface BubbleLayoutConfig {
288
+ /** Snuggest width; the bubble widens to its text up to {@link maxWidth}. */
289
+ readonly minWidth: number;
290
+ /** Widest the bubble grows before its text wraps to more lines. */
291
+ readonly maxWidth: number;
292
+ /** Minimum bubble height (px); grows past this to fit wrapped text. */
293
+ readonly height: number;
294
+ readonly padding: number;
295
+ /** Gap between the actor's head anchor and the bubble's bottom edge. */
296
+ readonly offsetY: number;
297
+ /** Body-text metrics the size measures with (the text view wraps to the same). */
298
+ readonly textSize: number;
299
+ readonly lineHeight: number;
300
+ readonly fontFamily?: string | undefined;
301
+ readonly bitmapFont?: string | undefined;
302
+ /** Anchor for a missing/absent speaker with no last-known position. Default
303
+ * world origin; point it at the camera centre for a pure-bubble bundle that
304
+ * shows narrator lines. */
305
+ readonly fallbackAnchor?: (() => AnchorPoint) | undefined;
306
+ }
307
+ /** A reserved portrait column INSIDE the bubble: the bubble grows to contain it
308
+ * and the body text reflows past it (the in-bubble avatar registers one). */
309
+ interface BubblePortraitInset {
310
+ readonly side: "left" | "right";
311
+ /** Full reserved column width (portrait + gap), px. */
312
+ readonly width: number;
313
+ /** Min content height the bubble clears for the portrait, px. */
314
+ readonly height: number;
315
+ }
316
+ declare class BubbleLayout {
317
+ private readonly cfg;
318
+ private readonly anchors;
319
+ /** One-line memo: the session presents one line to chrome then text, so a
320
+ * one-deep cache makes the second `sizeFor` free (no redundant measure pass). */
321
+ private memoLine;
322
+ private memoSize;
323
+ /** Reserved portrait column for the current line (set by the in-bubble avatar
324
+ * before the chrome/text present). */
325
+ private inset;
326
+ /** The current bubble content size — the say bubble (from {@link sizeFor}) or a
327
+ * choice panel (from {@link setChoicePanelSize}). The in-bubble avatar centres
328
+ * in this, so it follows whichever is on screen. */
329
+ private active;
330
+ private readonly listeners;
331
+ constructor(cfg: BubbleLayoutConfig);
332
+ /** Reserve (or clear with `undefined`) a portrait column inside the bubble.
333
+ * The bubble (and a bubble choice panel) then grows to contain it and the
334
+ * text/rows reflow to the narrowed column; the avatar sets this per line
335
+ * before the chrome/text/choices present. */
336
+ setPortraitInset(inset: BubblePortraitInset | undefined): void;
337
+ /** The reserved portrait column (or undefined) — a bubble choice presenter
338
+ * reads it to reflow its panel around the portrait. */
339
+ portraitInset(): BubblePortraitInset | undefined;
340
+ /** Register a callback fired when the active bubble content size changes (a
341
+ * say line sizes its bubble, or a choice commits its panel) — the in-bubble
342
+ * avatar re-places. */
343
+ onChange(listener: () => void): void;
344
+ /** The current bubble content size the avatar centres in (say bubble or choice
345
+ * panel). */
346
+ activeSize(): BubbleSize | undefined;
347
+ /** A bubble choice presenter commits its (inset-grown) panel size here so the
348
+ * in-bubble avatar follows the panel, not the say bubble. */
349
+ setChoicePanelSize(size: BubbleSize): void;
350
+ private setActive;
351
+ /** Inner padding (px) — the presenters position the name/caret/text by it. */
352
+ get padding(): number;
353
+ /** Gap between the speaker anchor and the bubble's bottom edge (px). */
354
+ get offsetY(): number;
355
+ /** Wire the missing-actor warning to the engine Logger (the controller's sink). */
356
+ setDiagnostics(warn: DiagnosticSink): void;
357
+ /** Outer bubble size to fit this line's text (+ a reserved portrait column,
358
+ * if one is registered) — measured once, then memoized for the companion
359
+ * presenter's read of the same line. */
360
+ sizeFor(line: PresentedLine): BubbleSize;
361
+ /** Body-text wrap width inside the bubble — the inner width minus the
362
+ * reserved portrait column (so the text reflows past an in-bubble avatar). */
363
+ textWrapWidth(size: BubbleSize): number;
364
+ /** World anchor for a speaker: a live {@link DialogueActor}'s head, else the
365
+ * last-known / fallback position (and a once-per-speaker warning). Shared by
366
+ * all three bubble presenters so they track the same actor. */
367
+ anchorFor(scene: Scene, speakerId: string | undefined): AnchorPoint;
368
+ /** Inner top-left a content-sized bubble's body sits at, from the speaker
369
+ * anchor + the bubble size (the once-derived origin formula). Shifts past a
370
+ * left-side portrait column so the text reflows beside it. */
371
+ originFor(anchor: AnchorPoint, size: BubbleSize): {
372
+ x: number;
373
+ y: number;
374
+ };
375
+ }
376
+
377
+ /**
378
+ * A {@link DialogueTextView} that lays its body text inside a diegetic bubble
379
+ * and follows the speaking actor. Per line it asks the shared {@link BubbleLayout}
380
+ * for the bubble size + the speaker anchor, and points the view's origin
381
+ * provider at the bubble's inner top-left. Because the size and anchor come from
382
+ * the SAME owner the companion {@link BubbleChrome} reads, the text always sits
383
+ * inside its frame — no per-presenter sizing copies to drift. All the
384
+ * typewriter / effect / markup machinery is inherited unchanged.
385
+ */
386
+
387
+ declare class BubbleTextView extends DialogueTextView {
388
+ private readonly layout;
389
+ private sceneRef?;
390
+ constructor(cfg: Omit<DialogueTextConfig, "box">, layout: BubbleLayout);
391
+ mount(scene: Scene): void;
392
+ /** Route the missing-actor warning to the engine Logger (the layout owns the
393
+ * shared anchor resolver). The base view has no diagnostics of its own. */
394
+ setDiagnostics(warn: DiagnosticSink): void;
395
+ present(line: PresentedLine): void;
396
+ }
397
+
398
+ /**
399
+ * DialogueTheme — the single flat visual-config object the dialogue factories
400
+ * consume. A theme is a plain data object (no behaviour) so it can be authored
401
+ * inline, imported from a preset module, or serialized.
402
+ *
403
+ * The factories ({@link createBoxDialogue}, {@link createBubbleDialogue},
404
+ * {@link createMixedDialogue}) map every field below onto the chrome / text /
405
+ * choice presenter configs. The mapping is mechanical: a presenter-config field
406
+ * has the SAME name as the theme field it comes from (e.g. theme `frameColor` →
407
+ * config `frameColor`), so drift is visible and a `dialogue.exhaustiveness`
408
+ * test asserts every field reaches a presenter.
409
+ *
410
+ * {@link defaultTheme} returns a zero-asset instance (Graphics chrome + canvas
411
+ * text, no `bitmapFont`/`textured`), so the factories work with no
412
+ * caller-supplied theme.
413
+ *
414
+ * Bitmap fonts (`bitmapFont`) are an OPT-IN crisp-pixel path. Textured
415
+ * nine-slice chrome is a separate opt-in re-theming path driven by the
416
+ * {@link textured} field.
417
+ */
418
+
419
+ /**
420
+ * Viewport-relative bounds for the dialogue box (virtual px). The box is a
421
+ * full-width bottom bar resolved against the renderer's design size at mount, so
422
+ * the default presenter works at ANY virtual resolution with no override: the
423
+ * width is `viewport.width - 2*marginX`, and the frame anchors `marginY` from the
424
+ * screen edge. `meta.position` reuses these: `bottom` (default) anchors at the
425
+ * bottom edge, `top` mirrors `marginY` to the top, `center` ignores it.
426
+ */
427
+ interface BoxBounds {
428
+ /** Horizontal margin from the left and right screen edges (virtual px). */
429
+ readonly marginX: number;
430
+ /** Vertical margin from the anchored screen edge — the bottom by default, the
431
+ * top for `meta.position: top` (the centred position ignores it). */
432
+ readonly marginY: number;
433
+ /** Box height (virtual px) — sized to hold the body text, not the screen, so
434
+ * it holds the same number of lines at any resolution. */
435
+ readonly height: number;
436
+ }
437
+ /** Continue-caret styling. The caret is the blinking "press to advance"
438
+ * triangle every chrome draws at its bottom-right; both fields are optional —
439
+ * omit them for the built-in defaults ({@link DEFAULT_CARET_BLINK_MS} /
440
+ * {@link DEFAULT_CARET_SIZE}). The nested-group shape is the convention the
441
+ * (cut) glossary `term` styling returns into. */
442
+ interface CaretTheme {
443
+ /** Blink time constant (ms) in `0.35 + 0.65·(0.5 + 0.5·sin(t/blinkMs))`.
444
+ * Larger = slower pulse. Default {@link DEFAULT_CARET_BLINK_MS}. */
445
+ readonly blinkMs?: number;
446
+ /** Triangle size (px). Default {@link DEFAULT_CARET_SIZE} (7×5, pointing down). */
447
+ readonly size?: {
448
+ readonly width: number;
449
+ readonly height: number;
450
+ };
451
+ }
452
+ interface DialogueTheme {
453
+ /** Box geometry as viewport-relative margins + height — a full-width bottom
454
+ * bar resolved against the renderer's design size, so it works at any
455
+ * resolution with no override. See {@link BoxBounds}. */
456
+ readonly box: BoxBounds;
457
+ /** Inner padding between the frame and its contents. */
458
+ readonly padding: number;
459
+ readonly frameColor: number;
460
+ readonly frameAlpha: number;
461
+ readonly borderColor: number;
462
+ readonly cornerRadius: number;
463
+ readonly nameColor: number;
464
+ readonly nameSize: number;
465
+ /** Blinking "continue" caret colour. */
466
+ readonly indicatorColor: number;
467
+ /** Continue-caret blink + size (optional; built-in defaults otherwise). */
468
+ readonly caret?: CaretTheme;
469
+ readonly textSize: number;
470
+ readonly lineHeight: number;
471
+ readonly textColor: number;
472
+ /** Base reveal rate (characters/second). */
473
+ readonly charsPerSec: number;
474
+ readonly choiceSize: number;
475
+ readonly choiceColor: number;
476
+ readonly choiceSelectedColor: number;
477
+ readonly highlightColor: number;
478
+ /** Vertical gap (px) between choice rows. One value for box and bubble lists
479
+ * (a per-bundle override stays possible via the presenter config). Optional;
480
+ * default {@link DEFAULT_CHOICE_GAP}. */
481
+ readonly choiceGap?: number;
482
+ /** Bubble tail tip offset from the speaker anchor (px), the little
483
+ * asymmetric "lean" of the pointer. Optional; default {@link DEFAULT_TAIL_LEAN}.
484
+ * Bubble *size* (min/max width, height, tail height) is geometry, set via
485
+ * `createBubbleDialogue`'s `bubble` option, not the theme. */
486
+ readonly tailLean?: {
487
+ readonly x: number;
488
+ readonly y: number;
489
+ };
490
+ /**
491
+ * Baked bitmap-font name (OPT-IN). Omit for canvas text. When set, the
492
+ * presenters render with the crisp pixel atlas instead of canvas SplitText.
493
+ * Bold/italic are synthesised on the regular atlas (skew + double-draw);
494
+ * variant-atlas fields will return if a baseline-compensating crisp path
495
+ * lands in the renderer.
496
+ */
497
+ readonly bitmapFont?: string;
498
+ /** Canvas font family (used when {@link bitmapFont} is omitted). */
499
+ readonly fontFamily?: string;
500
+ /** Canvas render resolution (used when not bitmap). */
501
+ readonly resolution?: number;
502
+ /** Layer for the frame + selection highlight + continue caret. */
503
+ readonly layerFrame: string;
504
+ /** Layer for all text (name, body, choice labels). */
505
+ readonly layerText: string;
506
+ /** Hold-to-fast-forward multiplier. Default 4 (applied by the session). */
507
+ readonly skipMultiplier?: number;
508
+ /**
509
+ * Optional textured nine-slice chrome (OPT-IN) — a MAP of named
510
+ * {@link ChromeStyle}s. A box line picks one by name through its
511
+ * `meta.chrome` key; the box chrome renders that style's `frame` as a
512
+ * stretchable nine-slice instead of the drawn rounded rect, and the bubble
513
+ * renders the `"default"` style's `bubble` (if any). Two style names are
514
+ * reserved:
515
+ * - `"default"` — the look used for box lines with no (or an unknown)
516
+ * `meta.chrome`. Omit it to keep the drawn Graphics frame as the default.
517
+ * - `"none"` — built-in (needs no entry); hides the box frame entirely for
518
+ * that line (e.g. a full-bleed narration line).
519
+ *
520
+ * Leave `textured` undefined for the Graphics-only default path.
521
+ */
522
+ readonly textured?: Readonly<Record<string, ChromeStyle>>;
523
+ }
524
+ /**
525
+ * NineSliceInsets — the four immutable border widths (in source-texture pixels)
526
+ * that define a nine-slice frame. The center + edges stretch; the corners stay
527
+ * fixed. Matches Pixi's NineSliceSprite `leftWidth`/`topHeight`/etc.
528
+ */
529
+ interface NineSliceInsets {
530
+ /** Fixed-width left border in texture pixels. */
531
+ readonly left: number;
532
+ /** Fixed-height top border in texture pixels. */
533
+ readonly top: number;
534
+ /** Fixed-width right border in texture pixels. */
535
+ readonly right: number;
536
+ /** Fixed-height bottom border in texture pixels. */
537
+ readonly bottom: number;
538
+ }
539
+ /**
540
+ * A single nine-slice texture frame: the source texture plus its border insets.
541
+ * Textures are referenced by {@link TextureInput} (string key or Texture) so the
542
+ * theme stays serializable. Used for both box frames and speech bubbles.
543
+ */
544
+ interface NineSliceFrame {
545
+ /** Nine-slice texture (string asset key or a resolved Texture). */
546
+ readonly texture: TextureInput;
547
+ /** Border insets for {@link texture}, in source-texture pixels. */
548
+ readonly insets: NineSliceInsets;
549
+ }
550
+ /**
551
+ * A named chrome style: a box-frame nine-slice and, optionally, a matching
552
+ * speech-bubble nine-slice. A box line selects a style by name via its
553
+ * `meta.chrome` key (see {@link DialogueTheme.textured}); the speech bubble uses
554
+ * the `"default"` style's `bubble` for every bubble line (per-line bubble
555
+ * variants are not a thing — bubbles are diegetic, anchored to an actor).
556
+ */
557
+ interface ChromeStyle {
558
+ /** Box-frame nine-slice for this style. */
559
+ readonly frame: NineSliceFrame;
560
+ /** Speech-bubble nine-slice (only the `"default"` style's `bubble` is read).
561
+ * Omit to keep the drawn Graphics bubble. */
562
+ readonly bubble?: NineSliceFrame;
563
+ }
564
+ /** Default continue-caret blink time constant (ms). */
565
+ declare const DEFAULT_CARET_BLINK_MS = 260;
566
+ /** Default continue-caret triangle size (px), pointing down. */
567
+ declare const DEFAULT_CARET_SIZE: {
568
+ readonly width: number;
569
+ readonly height: number;
570
+ };
571
+ /** Default vertical gap (px) between choice rows (box + bubble). */
572
+ declare const DEFAULT_CHOICE_GAP = 6;
573
+ /** Default bubble tail tip offset from the speaker anchor (px). */
574
+ declare const DEFAULT_TAIL_LEAN: {
575
+ readonly x: number;
576
+ readonly y: number;
577
+ };
578
+ /** Reserved `meta.chrome` / {@link DialogueTheme.textured} key: the box look
579
+ * used when a line carries no (or an unknown) `meta.chrome`. */
580
+ declare const CHROME_STYLE_DEFAULT = "default";
581
+ /** Reserved `meta.chrome` value (built-in, needs no `textured` entry): hide the
582
+ * box frame for that line. */
583
+ declare const CHROME_STYLE_NONE = "none";
584
+
585
+ /**
586
+ * BoxLayout — the single per-line geometry owner for the bottom-box coordinate
587
+ * model (the box half of the "layout owner"). One instance is shared by the box
588
+ * chrome, the box text view, the box choice list, and an in-box avatar
589
+ * presenter, so the **frame, nameplate, prompt, and choice rows move and grow as
590
+ * ONE coherent panel** instead of each presenter holding its own copy.
591
+ *
592
+ * It owns three things the presenters would otherwise compute independently:
593
+ *
594
+ * - **Per-line position** — `meta.position` (`top|center|bottom`) places the
595
+ * frame within the design viewport (the renderer's `virtualSize`, bound at
596
+ * mount via {@link setViewport}); the frame AND the text region move together.
597
+ * The box is a full-width bottom bar resolved from viewport-relative margins,
598
+ * so the default presenter works at any resolution with no override.
599
+ * - **Unified panel grow** — for a choice, the frame grows to fit the nameplate
600
+ * + prompt + rows; the row rects are stacked inside it (see
601
+ * `stackChoiceRows`). Growing is bottom-anchored at "bottom", so the frame top
602
+ * rises and the chrome/nameplate/prompt follow.
603
+ * - **Inset registry** — a presenter reserves a left/right column; the text
604
+ * region subtracts it so the body text reflows around it (the reference
605
+ * in-box avatar registers one).
606
+ *
607
+ * Presenters call {@link onChange} to re-place when the committed frame changes
608
+ * (a choice grows the frame after the chrome/text already presented).
609
+ */
610
+
611
+ /** RPG-Maker-style vertical placement of the box within the field. */
612
+ type BoxPosition = "top" | "center" | "bottom";
613
+ /** A reserved column the body text reflows around (the avatar-reflow seam). */
614
+ interface TextInset {
615
+ readonly side: "left" | "right";
616
+ readonly width: number;
617
+ }
618
+ /** A laid-out rectangle (screen px). */
619
+ interface Rect {
620
+ readonly x: number;
621
+ readonly y: number;
622
+ readonly width: number;
623
+ readonly height: number;
624
+ }
625
+ /** A choice row's screen rect — shared by placement, highlight, and hit-test. */
626
+ interface ChoiceRowRect {
627
+ readonly x: number;
628
+ readonly y: number;
629
+ readonly width: number;
630
+ readonly height: number;
631
+ }
632
+ interface BoxLayoutConfig {
633
+ /** Viewport-relative box bounds (margins + height); the frame is resolved
634
+ * against the design viewport set by {@link BoxLayout.setViewport} at mount. */
635
+ readonly box: BoxBounds;
636
+ readonly padding: number;
637
+ /** Nameplate band height (theme.nameSize) — body text starts below it. */
638
+ readonly nameSize: number;
639
+ /** Body text metrics — for measuring an optional choice prompt's height. */
640
+ readonly textSize: number;
641
+ readonly lineHeight: number;
642
+ /** Vertical gap between choice rows (for the grown panel). */
643
+ readonly choiceGap: number;
644
+ readonly fontFamily?: string | undefined;
645
+ readonly bitmapFont?: string | undefined;
646
+ }
647
+ /**
648
+ * Stack choice-row slots bottom-up inside `box`, growing **upward** from the
649
+ * bottom edge. `rowHeights` are full slot heights (wrapped text height + gap).
650
+ * Rows are always contiguous and non-overlapping. Inside a frame the owner grew
651
+ * to fit, the topmost row lands right below the prompt; in a too-tall menu the
652
+ * excess spills off the top (the soft-cap advisory flags that). The single
653
+ * source of row geometry: placement, highlight, and hit-test all consume it.
654
+ */
655
+ declare function stackChoiceRows(rowHeights: readonly number[], box: Rect, padding: number): ChoiceRowRect[];
656
+ declare class BoxLayout {
657
+ private readonly cfg;
658
+ /** Design viewport (the renderer's `virtualSize`) the box is placed within —
659
+ * bound at mount via {@link setViewport}. Defaults to a sane size so headless
660
+ * use (no renderer) and pre-mount calls still produce a valid frame. */
661
+ private viewW;
662
+ private viewH;
663
+ private readonly insets;
664
+ private readonly listeners;
665
+ /** The committed frame — moved by `meta.position`, grown for a choice. */
666
+ private frame;
667
+ /** The line currently laid out (for the choice panel's prompt + nameplate). */
668
+ private line;
669
+ constructor(cfg: BoxLayoutConfig);
670
+ /**
671
+ * Bind the design viewport (the renderer's `virtualSize`), read at mount, so
672
+ * the box is a full-width bottom bar at any resolution and `meta.position`
673
+ * places the frame against the true screen. Recomputes the resting frame.
674
+ */
675
+ setViewport(width: number, height: number): void;
676
+ /** Register a callback fired when the committed frame changes (a choice grows
677
+ * it, or an inset reflows the text) — the chrome redraws, the text re-places. */
678
+ onChange(listener: () => void): void;
679
+ /** The frame rect for the current line (read by the chrome to draw + place). */
680
+ frameRect(): Rect;
681
+ /** Inner content width — choice rows wrap to this. Frame minus padding minus
682
+ * any registered insets, so choices reflow around an in-box avatar the same
683
+ * way the body text does. */
684
+ contentWidth(): number;
685
+ /** Inner padding between the frame and its contents — an in-box presenter
686
+ * aligns its column to this so it sits inside the border, like the text. */
687
+ padding(): number;
688
+ /** Lay out a say/prompt line: place the base-height frame at its
689
+ * `meta.position`. Commits the frame (firing {@link onChange} if it moved). */
690
+ layoutLine(line: PresentedLine | undefined): Rect;
691
+ /**
692
+ * Grow the frame to fit a choice: nameplate band + optional prompt + the
693
+ * rows, capped at the field. Commits the grown frame (firing {@link onChange}
694
+ * so the chrome/nameplate/prompt follow) and returns the row rects stacked
695
+ * inside it.
696
+ */
697
+ layoutChoicePanel(rowHeights: readonly number[]): ChoiceRowRect[];
698
+ /** Body-text region inside the current frame: below the nameplate band, inset
699
+ * by padding, minus any registered insets (so text reflows around an avatar).
700
+ * For a choice, this is the prompt region above the rows. */
701
+ textRegion(): {
702
+ x: number;
703
+ y: number;
704
+ width: number;
705
+ };
706
+ /** Top-left of the nameplate inside the current frame. */
707
+ nameplatePos(): {
708
+ x: number;
709
+ y: number;
710
+ };
711
+ /** Bottom-right continue-caret position inside the current frame. */
712
+ caretPos(size: {
713
+ width: number;
714
+ height: number;
715
+ }): {
716
+ x: number;
717
+ y: number;
718
+ };
719
+ /**
720
+ * Reserve (or clear with `undefined`) a left/right column the body text
721
+ * reflows around — the avatar-reflow seam. The reference in-box avatar
722
+ * presenter registers one keyed by its own id. Fires {@link onChange} so a
723
+ * text view already showing this line reflows.
724
+ */
725
+ setInset(key: string, inset: TextInset | undefined): void;
726
+ /** The reserved width on a side (0 if none) — an avatar presenter reads it to
727
+ * place itself in the column it reserved. */
728
+ insetWidth(side: "left" | "right"): number;
729
+ /** Distance from the frame top to the body text (nameplate band + gap). */
730
+ private bodyOffset;
731
+ /** Measure the current choice line's prompt (0 when there is none). */
732
+ private promptHeight;
733
+ /** Place a full-width frame of `height` at `position` within the design
734
+ * viewport: `bottom` anchors `marginY` from the bottom edge, `top` mirrors it
735
+ * to the top, `center` centres. The width is the viewport minus side margins. */
736
+ private frameAt;
737
+ private commit;
738
+ private notify;
739
+ }
740
+
741
+ /**
742
+ * A {@link DialogueTextView} that reads its body-text region from the shared
743
+ * {@link BoxLayout} instead of a fixed box. Per line it wraps to the owner's
744
+ * current text region (which the owner narrows for a registered avatar inset, so
745
+ * the text reflows around it) and tracks the region's top-left via the origin
746
+ * provider, so when `meta.position` moves the frame or a choice grows it, the
747
+ * body follows. This mirrors {@link BubbleTextView}, which follows the speaker
748
+ * anchor the same way.
749
+ */
750
+
751
+ declare class BoxTextView extends DialogueTextView {
752
+ private readonly layout;
753
+ constructor(cfg: Omit<DialogueTextConfig, "box">, layout: BoxLayout);
754
+ present(line: PresentedLine): void;
755
+ }
756
+
757
+ /**
758
+ * Screen-space render layers the dialogue system draws into. They sit ABOVE
759
+ * the auto-provisioned ui-react layer (order 1000) so a conversation overlays
760
+ * any in-scene React chrome, and `space: "screen"` pins the box to the
761
+ * viewport (it doesn't scroll/zoom with the world camera).
762
+ *
763
+ * A host scene opts in by spreading `DIALOGUE_LAYERS` into its `layers` field:
764
+ * readonly layers = [...DIALOGUE_LAYERS];
765
+ */
766
+ declare const DIALOGUE_LAYER_FRAME = "dialogue-frame";
767
+ declare const DIALOGUE_LAYER_TEXT = "dialogue-text";
768
+ declare const DIALOGUE_LAYER_AVATAR = "dialogue-avatar";
769
+ declare const DIALOGUE_LAYERS: readonly LayerDef[];
770
+
771
+ /**
772
+ * Per-glyph animated text effects. The line is one `SplitTextComponent`; we
773
+ * animate each glyph node (`chars[i]`) individually, phased by its position
774
+ * along the line — so `[wave]` ripples letter-by-letter, `[shake]` jitters each
775
+ * glyph, `[rainbow]` cycles per glyph. Effects are pure functions of time + the
776
+ * glyph's resting x (the phase), so they're deterministic and snapshot-safe.
777
+ *
778
+ * The effect name is an OPEN vocabulary (any `[name]` markup tag). This bundled
779
+ * evaluator animates the four built-ins (wave / shake / pulse / rainbow — the
780
+ * `BuiltinEffectId`s) and treats any other name — one a custom text channel owns,
781
+ * or a typo — as a no-op (identity transform), so the run renders as plain styled
782
+ * text.
783
+ */
784
+ /** Mutable so a per-frame caller can reuse one scratch instance (see `out`). */
785
+ interface EffectOutput {
786
+ /** Offset from the run's resting position, in px. */
787
+ dx: number;
788
+ dy: number;
789
+ /** Uniform scale multiplier (1 = none). */
790
+ scale: number;
791
+ /** Tint override (0xRRGGBB), or undefined to keep the run's base colour. */
792
+ tint: number | undefined;
793
+ }
794
+ /**
795
+ * @param effect which effect by name (undefined or an unrecognized name → no motion)
796
+ * @param timeMs elapsed time the run has been on screen
797
+ * @param phase a per-run phase seed (use the run's resting x) so adjacent
798
+ * runs animate out of sync instead of in lockstep
799
+ * @param out optional scratch object, reset and returned — pass one per
800
+ * caller to avoid an allocation per animated glyph per frame
801
+ */
802
+ declare function evaluateEffect(effect: string | undefined, timeMs: number, phase: number, out?: EffectOutput): EffectOutput;
803
+ /** True if the effect needs a tint each frame (so the view skips static tint).
804
+ * False for an unrecognized name (it animates nothing). */
805
+ declare function effectDrivesTint(effect: string | undefined): boolean;
806
+
807
+ /**
808
+ * Default chrome — draws the dialogue box frame, the name plate, and the
809
+ * blinking "continue" caret with the renderer (Graphics + Text on screen-space
810
+ * layers), so this addon needs only renderer + core, not ui-react. The choice
811
+ * list lives in its own {@link ChoiceListPresenter}; the body text lives in
812
+ * {@link DialogueTextView}. This class owns only the frame + nameplate + caret,
813
+ * which makes z-order deterministic and the seams swappable independently.
814
+ *
815
+ * Geometry comes from the shared {@link BoxLayout}: the frame rect (moved per
816
+ * line by `meta.position`, grown to fit a choice's rows), the nameplate spot,
817
+ * and the caret spot. The chrome subscribes to the owner so when a choice grows
818
+ * the frame after the chrome already presented, it redraws + repositions — the
819
+ * frame, nameplate, prompt, and rows stay ONE coherent panel.
820
+ *
821
+ * The frame renders one of three ways per line, chosen by the line's
822
+ * `meta.chrome` key (box only): a named {@link NineSliceFrame} from
823
+ * {@link DialogueChromeConfig.frameStyles}, the built-in `"none"` (no frame), or
824
+ * the drawn Graphics rounded rect (the default).
825
+ */
826
+
827
+ interface DialogueChromeConfig extends FontConfig {
828
+ readonly frameColor: number;
829
+ readonly frameAlpha: number;
830
+ readonly borderColor: number;
831
+ readonly cornerRadius: number;
832
+ readonly nameColor: number;
833
+ readonly nameSize: number;
834
+ readonly indicatorColor: number;
835
+ /** Continue-caret blink + size (built-in defaults when omitted). */
836
+ readonly caret?: CaretTheme | undefined;
837
+ /** Named nine-slice box-frame styles, keyed to match `meta.chrome`. The
838
+ * reserved `"default"` entry is the no-meta look; `"none"` is built-in and
839
+ * needs no entry. Omit the whole field for the Graphics-only default. */
840
+ readonly frameStyles?: Readonly<Record<string, NineSliceFrame>> | undefined;
841
+ /** Frame + continue indicator. */
842
+ readonly layerFrame: string;
843
+ /** Name plate (drawn above the frame layer). */
844
+ readonly layerText: string;
845
+ }
846
+ declare class DialogueChrome implements ChromePresenter {
847
+ private readonly cfg;
848
+ private readonly layout;
849
+ private frame?;
850
+ private frameGfx?;
851
+ /** Separate entity hosting the nine-slice sprites (one per textured style);
852
+ * only spawned when {@link DialogueChromeConfig.frameStyles} has entries. Its
853
+ * Transform tracks the per-line frame origin so the sprites draw at local 0. */
854
+ private frameTex?;
855
+ private frameTexTransform?;
856
+ private nineSliceHost?;
857
+ private readonly nineSlices;
858
+ private name?;
859
+ private indicator?;
860
+ private indicatorTime;
861
+ /** Selected textured-style name from the line's `meta.chrome`, or undefined
862
+ * when the line names none. */
863
+ private styleKey;
864
+ private warn?;
865
+ private readonly warnedKeys;
866
+ /** Master gate (from {@link setVisible}); the Session drives it. Hidden at
867
+ * mount until a line shows. The name/caret also need their own content
868
+ * sub-state — each renders only when shown AND its content is present. */
869
+ private visible;
870
+ private nameShown;
871
+ private caretShown;
872
+ constructor(cfg: DialogueChromeConfig, layout: BoxLayout);
873
+ /** Route the unknown-`meta.chrome` warning to the engine Logger. */
874
+ setDiagnostics(warn: DiagnosticSink): void;
875
+ mount(scene: Scene): void;
876
+ setNameplate(name: string | undefined, color?: number): void;
877
+ setContinueVisible(visible: boolean): void;
878
+ /** Place this line's frame at its `meta.position` and pick its `meta.chrome`
879
+ * style. Box only; the bubble ignores both. `undefined` (no line) resets to
880
+ * the default look at the resting position. */
881
+ present(line: PresentedLine | undefined): void;
882
+ /** Show or hide the whole box. State-preserving — the name/caret content
883
+ * sub-state survives, so showing again restores exactly what was up. */
884
+ setVisible(visible: boolean): void;
885
+ /** Redraw the frame + reposition the nameplate and caret from the owner's
886
+ * current geometry (per line, and when a choice grows the frame). */
887
+ private applyGeometry;
888
+ /** Render each piece = master-visible AND its own content present. */
889
+ private apply;
890
+ update(dt: number): void;
891
+ dispose(): void;
892
+ }
893
+
894
+ /**
895
+ * Default choice presenter — a vertical list inside the box, with a highlight
896
+ * bar behind the selected row. Split out of `DialogueChrome` so the choice UI is
897
+ * swappable (a radial / Mass-Effect wheel, a separate panel, a touch list)
898
+ * without touching the frame or body text. Selection *navigation* lives in the
899
+ * Session; this presenter only paints what it's told and reports pointer commits
900
+ * back through {@link ChoiceChannel.onChoiceChosen}.
901
+ *
902
+ * Overflow + unified panel: labels word-wrap (a row may be several lines), and
903
+ * the row stack is laid out by the shared {@link BoxLayout}, which **grows the
904
+ * surrounding frame** to fit the rows + prompt + nameplate as one panel (a list
905
+ * too tall for the screen spills off the bottom, non-overlapping). Row
906
+ * placement, the selection highlight, and pointer hit-testing all derive from
907
+ * the ONE set of row rects the owner returns, so a wrapped/overflowing row can't
908
+ * desync them. A list longer than `softMaxChoices` logs a soft-cap advisory.
909
+ */
910
+
911
+ interface ChoiceListConfig extends FontConfig {
912
+ readonly choiceSize: number;
913
+ readonly choiceColor: number;
914
+ readonly choiceSelectedColor: number;
915
+ readonly highlightColor: number;
916
+ /** Vertical gap (px) between choice rows. Default {@link DEFAULT_CHOICE_GAP}. */
917
+ readonly choiceGap?: number | undefined;
918
+ /** Soft cap on option count: more than this logs an advisory (the list still
919
+ * grows to fit). Default {@link DEFAULT_SOFT_MAX_CHOICES}. */
920
+ readonly softMaxChoices?: number | undefined;
921
+ /** Selection highlight bar. */
922
+ readonly layerFrame: string;
923
+ /** Choice labels (drawn above the frame layer). */
924
+ readonly layerText: string;
925
+ }
926
+ declare class ChoiceListPresenter implements ChoicePresenter {
927
+ private readonly cfg;
928
+ private readonly layout;
929
+ private scene?;
930
+ private highlightBar?;
931
+ private rows;
932
+ private selected;
933
+ private warn?;
934
+ /** Master visibility gate — hides the list WITHOUT clearing it, so a
935
+ * hide/show round-trip keeps the rows + selection. */
936
+ private hidden;
937
+ onChoiceChosen?: (position: number) => void;
938
+ constructor(cfg: ChoiceListConfig, layout: BoxLayout);
939
+ /** Route the soft-cap advisory to the engine Logger. */
940
+ setDiagnostics(warn: DiagnosticSink): void;
941
+ mount(scene: Scene): void;
942
+ present(choices: readonly PresentedChoice[]): void;
943
+ highlight(position: number): void;
944
+ /** Show or hide the list without clearing it — state-preserving. */
945
+ setVisible(visible: boolean): void;
946
+ /** Render = rows present AND not hidden. Disabled rows still show (greyed). */
947
+ private applyHidden;
948
+ /** {@link PointerChoiceTarget}: which row (if any) a screen point falls in —
949
+ * from the same grown rects the rows are drawn at. */
950
+ choiceAtPoint(x: number, y: number): number | undefined;
951
+ clear(): void;
952
+ dispose(): void;
953
+ private highlightAt;
954
+ private drawHighlight;
955
+ private textOptions;
956
+ }
957
+
958
+ /**
959
+ * Diegetic speech-bubble chrome: a rounded bubble + downward tail drawn on a
960
+ * *world* layer, repositioned every frame to sit above the speaking actor's
961
+ * head. The per-line size, the speaker→world anchor (incl. the missing-actor
962
+ * fallback), and the inner-top-left origin all come from the shared
963
+ * {@link BubbleLayout} — the single owner the companion {@link BubbleTextView}
964
+ * and {@link BubbleChoicePresenter} read too, so they can never drift. This
965
+ * class keeps only the bubble's *drawing* config (colours, tail, caret, name).
966
+ *
967
+ * The bubble is content-sized per line (see {@link BubbleLayout.sizeFor}); the
968
+ * companion text view wraps to the same inner width so they stay aligned. With a
969
+ * textured {@link BubbleChromeConfig.frame}, the body renders as a nine-slice
970
+ * stretched to that same per-line size (the tail stays a drawn triangle).
971
+ */
972
+
973
+ interface BubbleChromeConfig extends FontConfig {
974
+ /** World-space render layer. */
975
+ readonly layer: string;
976
+ /** Tail height (the little pointer toward the speaker). */
977
+ readonly tail: number;
978
+ /** Tail tip offset from the speaker anchor (the asymmetric "lean"). */
979
+ readonly tailLean?: {
980
+ readonly x: number;
981
+ readonly y: number;
982
+ } | undefined;
983
+ readonly frameColor: number;
984
+ readonly frameAlpha: number;
985
+ readonly borderColor: number;
986
+ readonly cornerRadius: number;
987
+ readonly nameColor: number;
988
+ readonly nameSize: number;
989
+ readonly indicatorColor: number;
990
+ /** Continue-caret blink + size (built-in defaults when omitted). */
991
+ readonly caret?: CaretTheme | undefined;
992
+ /** Optional textured nine-slice for the bubble body (the `"default"` style's
993
+ * `bubble`). Resized per line; omit for the drawn Graphics bubble. */
994
+ readonly frame?: NineSliceFrame | undefined;
995
+ }
996
+ declare class BubbleChrome implements ChromePresenter {
997
+ private readonly cfg;
998
+ private readonly layout;
999
+ private scene?;
1000
+ private root?;
1001
+ private gfx?;
1002
+ private transform?;
1003
+ /** Nine-slice body sprite (child of {@link gfx}) when textured; resized per
1004
+ * line. The tail stays a drawn triangle on {@link gfx}. */
1005
+ private bubbleSlice?;
1006
+ private name?;
1007
+ private nameTransform?;
1008
+ private caret?;
1009
+ private caretTransform?;
1010
+ private caretTime;
1011
+ /** Current (content-sized) bubble size; recomputed per line from the layout. */
1012
+ private currentWidth;
1013
+ private currentHeight;
1014
+ private visible;
1015
+ private hasLine;
1016
+ private nameShown;
1017
+ private caretShown;
1018
+ /** Speaker id of the line on screen — re-resolved each frame by `follow()` so
1019
+ * the bubble tracks a live actor and falls back when one is missing. */
1020
+ private speakerId;
1021
+ constructor(cfg: BubbleChromeConfig, layout: BubbleLayout);
1022
+ /** Route the missing-actor warning to the engine Logger (the layout owns the
1023
+ * shared anchor resolver). */
1024
+ setDiagnostics(warn: DiagnosticSink): void;
1025
+ mount(scene: Scene): void;
1026
+ /** Re-anchor to the line's speaker, grow to fit the text, and reveal. The
1027
+ * bubble stays visible even when the speaker has no live actor — the layout
1028
+ * anchors it at the last-known / fallback position instead of vanishing. */
1029
+ present(line: PresentedLine | undefined): void;
1030
+ setNameplate(name: string | undefined): void;
1031
+ setContinueVisible(visible: boolean): void;
1032
+ /** Show or hide the whole bubble — state-preserving: the line, name, and
1033
+ * caret content survive a hide, so showing again restores them in place
1034
+ * (used by a composite chrome to hide the bubble while a box line plays). */
1035
+ setVisible(visible: boolean): void;
1036
+ /** Render each piece = master-visible AND a line is up AND its content present. */
1037
+ private apply;
1038
+ update(dt: number): void;
1039
+ dispose(): void;
1040
+ /** Move the bubble + name + caret to sit above the speaker — its live actor,
1041
+ * or the last-known / fallback anchor when the actor is missing. */
1042
+ private follow;
1043
+ private drawBubble;
1044
+ }
1045
+
1046
+ /**
1047
+ * A diegetic choice list: the options float in their own bubble panel over the
1048
+ * speaking actor (resolved via the {@link ActorRegistry} from the choice's
1049
+ * `context.speaker`), with their own background — so a bubble choice never
1050
+ * leans on the box frame (the source of the "frameless options" glitch). Pairs
1051
+ * with {@link BubbleChrome}/{@link BubbleTextView}; keyboard nav is Session-
1052
+ * driven, pointer hover/click come back via `onChoiceChosen` (world coords).
1053
+ *
1054
+ * The actor is static while a focused choice is up (the player is frozen), so
1055
+ * the panel is placed once on `present` rather than followed each frame.
1056
+ */
1057
+
1058
+ interface BubbleChoiceConfig extends FontConfig {
1059
+ /** World-space render layer (same as the bubble). */
1060
+ readonly layer: string;
1061
+ readonly width: number;
1062
+ readonly padding: number;
1063
+ /** Gap between the actor's head anchor and the panel's bottom edge. */
1064
+ readonly offsetY: number;
1065
+ readonly tail: number;
1066
+ readonly choiceSize: number;
1067
+ readonly choiceColor: number;
1068
+ readonly choiceSelectedColor: number;
1069
+ readonly highlightColor: number;
1070
+ /** Vertical gap (px) between choice rows. Default {@link DEFAULT_CHOICE_GAP}. */
1071
+ readonly choiceGap?: number | undefined;
1072
+ /** Colour for the optional prompt header rendered inside the panel — the
1073
+ * theme's body `textColor`. */
1074
+ readonly textColor: number;
1075
+ readonly frameColor: number;
1076
+ readonly frameAlpha: number;
1077
+ readonly borderColor: number;
1078
+ readonly cornerRadius: number;
1079
+ }
1080
+ declare class BubbleChoicePresenter implements ChoicePresenter {
1081
+ private readonly cfg;
1082
+ private readonly layout;
1083
+ readonly pointerSpace: "world";
1084
+ private scene?;
1085
+ private bg?;
1086
+ private highlightBar?;
1087
+ private prompt?;
1088
+ private rows;
1089
+ private selected;
1090
+ /** Master visibility gate — state-preserving hide/show. */
1091
+ private hidden;
1092
+ onChoiceChosen?: (position: number) => void;
1093
+ constructor(cfg: BubbleChoiceConfig, layout: BubbleLayout);
1094
+ /** Route the missing-actor warning to the engine Logger (the layout owns the
1095
+ * shared anchor resolver). */
1096
+ setDiagnostics(warn: DiagnosticSink): void;
1097
+ /** This panel is self-contained (own bg + prompt header), so it owns the
1098
+ * prompt — the Session hides the chrome/body prompt for these choices. */
1099
+ ownsPrompt(): boolean;
1100
+ mount(scene: Scene): void;
1101
+ present(choices: readonly PresentedChoice[], context?: ChoiceContext): void;
1102
+ /** Show or hide the whole panel without clearing it — state-preserving. */
1103
+ setVisible(visible: boolean): void;
1104
+ /** Render every piece (bg, prompt, rows, highlight) gated by the master.
1105
+ * Disabled rows still show (greyed); only the highlight bar is suppressed. */
1106
+ private applyHidden;
1107
+ highlight(position: number): void;
1108
+ /** {@link ChoicePresenter}: which option a *world* point falls in. */
1109
+ choiceAtPoint(x: number, y: number): number | undefined;
1110
+ clear(): void;
1111
+ dispose(): void;
1112
+ private drawPanel;
1113
+ private drawHighlight;
1114
+ private textOptions;
1115
+ }
1116
+
1117
+ /**
1118
+ * A Mass-Effect-style radial choice wheel — an alternative {@link ChoicePresenter}
1119
+ * that proves the choice seam is swappable without touching the Session. Options
1120
+ * are placed evenly around a centre hub; the Session's up/down nav still cycles
1121
+ * them, and pointer hover/click works via {@link ChoicePresenter.choiceAtPoint}.
1122
+ *
1123
+ * @experimental This presenter is an unpolished spike. Geometry, hit radius, and
1124
+ * styling are intentionally minimal; the API may change. Reach it via the
1125
+ * `./presenters` subpath; it is not part of the default factory bundles.
1126
+ */
1127
+
1128
+ interface RadialChoiceConfig extends FontConfig {
1129
+ readonly center: {
1130
+ readonly x: number;
1131
+ readonly y: number;
1132
+ };
1133
+ readonly radius: number;
1134
+ readonly choiceColor: number;
1135
+ readonly choiceSelectedColor: number;
1136
+ readonly hubColor: number;
1137
+ /** Choice label size (px) — matches the theme's `choiceSize`. */
1138
+ readonly choiceSize: number;
1139
+ readonly layerFrame: string;
1140
+ readonly layerText: string;
1141
+ }
1142
+ /** @experimental Unpolished radial choice wheel; see file header. */
1143
+ declare class RadialChoicePresenter implements ChoicePresenter {
1144
+ private readonly cfg;
1145
+ private scene?;
1146
+ private hub?;
1147
+ private spokes;
1148
+ private selected;
1149
+ /** Master visibility gate — state-preserving hide/show. */
1150
+ private hidden;
1151
+ onChoiceChosen?: (position: number) => void;
1152
+ constructor(cfg: RadialChoiceConfig);
1153
+ mount(scene: Scene): void;
1154
+ present(choices: readonly PresentedChoice[]): void;
1155
+ highlight(position: number): void;
1156
+ /** Show or hide the wheel without clearing it — state-preserving. */
1157
+ setVisible(visible: boolean): void;
1158
+ private applyHidden;
1159
+ /** {@link ChoicePresenter}: nearest spoke within a small radius. */
1160
+ choiceAtPoint(x: number, y: number): number | undefined;
1161
+ clear(): void;
1162
+ dispose(): void;
1163
+ private drawHub;
1164
+ }
1165
+
1166
+ /**
1167
+ * Box-vs-bubble routing for the three composite presenters. A route maps a
1168
+ * {@link PresentedLine} (or a choice's context, adapted to one) to `"box"` or
1169
+ * `"bubble"`. All three composites in a mixed bundle MUST consult the SAME
1170
+ * route, or a line could send its chrome to the bubble and its text to the box.
1171
+ *
1172
+ * The default policy is **speaker-aware**: a narrator goes to the box (no head
1173
+ * to float a bubble over), an explicit `view` hint wins for a real speaker, and
1174
+ * otherwise a speaker with a registered {@link DialogueActor} floats in a bubble
1175
+ * while one without falls to the box. Because the registry is scene-scoped, the
1176
+ * default is built as a {@link MountRoute} whose `hasActor` lookup is bound at
1177
+ * mount; `createMixedDialogue` shares one instance across the three composites
1178
+ * and exposes a `route` override for a custom policy ("all of NPC X in bubbles").
1179
+ */
1180
+
1181
+ /** Decides which variant a line renders in. Reads `view`/`speaker`/`meta`; a
1182
+ * custom route can key off any of them (e.g. `meta.aside → bubble`). */
1183
+ type CompositeRoute = (line: PresentedLine | undefined) => "box" | "bubble";
1184
+ /**
1185
+ * A route paired with a scene-bind hook the composites call at `mount`. The
1186
+ * default route needs the scene to resolve registered actors; a caller-supplied
1187
+ * route needs nothing, so its {@link bind} is a no-op. The composites store ONE
1188
+ * of these (shared, for a mixed bundle) and route every line/choice through it.
1189
+ */
1190
+ interface MountRoute {
1191
+ readonly route: CompositeRoute;
1192
+ /** Bind the scene at mount (idempotent across the three composites). */
1193
+ bind(scene: Scene): void;
1194
+ }
1195
+ /**
1196
+ * The default route decision as a pure function of the line + an actor lookup
1197
+ * (testable without a scene). Precedence:
1198
+ * 1. **narrator** (no `speaker`) → **box** — the genre convention; a bubble
1199
+ * has no head to float over. A *positioned* narrator is the invisible-anchor
1200
+ * recipe (give it a speaker whose actor sits on an invisible entity).
1201
+ * 2. an explicit **`view`** hint wins for a real speaker (`"bubble"`/`"box"`) —
1202
+ * a `view:"bubble"` whose actor is missing still routes to the bubble path,
1203
+ * which renders at the fallback anchor rather than vanishing.
1204
+ * 3. otherwise a speaker with a **registered actor** → **bubble**; else **box**.
1205
+ */
1206
+ declare function routeWithActor(line: PresentedLine | undefined, hasActor: (speakerId: string) => boolean): "box" | "bubble";
1207
+ /**
1208
+ * Build the default {@link MountRoute}. `hasActor` resolves the speaker through
1209
+ * the scene's {@link ActorRegistry}, rebound at mount (before then it answers
1210
+ * "no actor", so a pre-mount route call still resolves via the narrator/`view`
1211
+ * rules).
1212
+ */
1213
+ declare function makeDefaultRoute(): MountRoute;
1214
+ /** Wrap a caller-supplied route as a {@link MountRoute} (no scene needed). */
1215
+ declare function fixedRoute(route: CompositeRoute): MountRoute;
1216
+
1217
+ /**
1218
+ * Routes each line to one of two text presenters by its `view` hint and whether
1219
+ * it has a speaker — e.g. a narrator's speakerless line types in the bottom box
1220
+ * while NPC `view:"bubble"` lines float over their heads, all in one
1221
+ * conversation. The inactive presenter is cleared so only one shows at a time.
1222
+ * Reveal state + skip/fast-forward proxy to whichever is active; both are ticked
1223
+ * each frame.
1224
+ */
1225
+
1226
+ declare class CompositeTextPresenter implements TextPresenter {
1227
+ private readonly box;
1228
+ private readonly bubble;
1229
+ private readonly routing;
1230
+ private active?;
1231
+ private revealListener?;
1232
+ private beatListener?;
1233
+ /** Master visibility gate from the Session's setVisible. */
1234
+ private visible;
1235
+ constructor(box: TextPresenter, bubble: TextPresenter, routing?: MountRoute);
1236
+ /** Register the Session's reveal-completed listener. */
1237
+ setRevealListener(listener: (() => void) | undefined): void;
1238
+ /** Register the Session's reveal-beat listener (ticks + inline markers). */
1239
+ setBeatListener(listener: ((beat: RevealBeat) => void) | undefined): void;
1240
+ private fireReveal;
1241
+ private fireBeat;
1242
+ setDiagnostics(warn: (message: string) => void): void;
1243
+ mount(scene: Scene): void;
1244
+ present(line: PresentedLine): void;
1245
+ /** Show/hide the body text — forwarded to both views (the inactive one is
1246
+ * cleared, so its setVisible is a no-op); state-preserving on the active. */
1247
+ setVisible(visible: boolean): void;
1248
+ completeReveal(): void;
1249
+ isRevealComplete(): boolean;
1250
+ isRevealing(): boolean;
1251
+ setSpeedMultiplier(multiplier: number): void;
1252
+ update(dt: number): void;
1253
+ clear(): void;
1254
+ dispose(): void;
1255
+ }
1256
+
1257
+ /**
1258
+ * Chrome counterpart to {@link CompositeTextPresenter}: shows the box frame for
1259
+ * box lines and the bubble for bubble lines, hiding the other. The Session
1260
+ * calls `setNameplate`/`setContinueVisible` *before* `present`, so those are
1261
+ * buffered and applied to whichever variant `present` then selects.
1262
+ *
1263
+ * Visibility is owned by the Session: it calls `setVisible(bool)`
1264
+ * after each transition, and this composite restores **only the active variant**
1265
+ * on show. `active` is therefore RETAINED across a hide (a cutscene
1266
+ * mid-bubble-line shows the bubble again, not an empty box).
1267
+ */
1268
+
1269
+ declare class CompositeChrome implements ChromePresenter {
1270
+ private readonly box;
1271
+ private readonly bubble;
1272
+ private readonly routing;
1273
+ private active?;
1274
+ private pendingName;
1275
+ private pendingContinue;
1276
+ /** Master gate from the Session's setVisible — composed with the active
1277
+ * variant's own content state. Hidden at mount. */
1278
+ private visible;
1279
+ constructor(box: ChromePresenter, bubble: ChromePresenter, routing?: MountRoute);
1280
+ mount(scene: Scene): void;
1281
+ setDiagnostics(warn: (message: string) => void): void;
1282
+ setNameplate(name: string | undefined, color?: number): void;
1283
+ setContinueVisible(visible: boolean): void;
1284
+ present(line: PresentedLine | undefined): void;
1285
+ /** Show/hide the chrome. On show, restore ONLY the active variant
1286
+ * and re-apply the buffered caret; the other stays hidden. On hide, hide both
1287
+ * but RETAIN `active` so the next show brings back the right one. */
1288
+ setVisible(visible: boolean): void;
1289
+ update(dt: number): void;
1290
+ dispose(): void;
1291
+ }
1292
+
1293
+ /**
1294
+ * Routes a choice list to the box list or a bubble panel by its
1295
+ * `context.view` — the choice counterpart to {@link CompositeTextPresenter} /
1296
+ * {@link CompositeChrome}. A box choice keeps the framed bottom list; a
1297
+ * `view:"bubble"` choice gets its own panel over the actor (so it never relies
1298
+ * on the box frame, which the composite chrome hides for bubble lines).
1299
+ */
1300
+
1301
+ declare class CompositeChoicePresenter implements ChoicePresenter {
1302
+ private readonly box;
1303
+ private readonly bubble;
1304
+ private readonly routing;
1305
+ private active?;
1306
+ /** Master visibility gate from the Session's setVisible. */
1307
+ private visible;
1308
+ onChoiceChosen?: (position: number) => void;
1309
+ constructor(box: ChoicePresenter, bubble: ChoicePresenter, routing?: MountRoute);
1310
+ /** The active list's pointer space (so the binding hit-tests correctly). */
1311
+ get pointerSpace(): "screen" | "world";
1312
+ setDiagnostics(warn: (message: string) => void): void;
1313
+ /** Routes to the variant this choice will use, so the Session knows whether
1314
+ * to suppress its chrome/body prompt before `present` picks the active one. */
1315
+ ownsPrompt(context?: ChoiceContext): boolean;
1316
+ mount(scene: Scene): void;
1317
+ present(choices: readonly PresentedChoice[], context?: ChoiceContext): void;
1318
+ /** Show/hide the choices — forwarded to both (the inactive one is
1319
+ * cleared, so its setVisible is a no-op); state-preserving on the active. */
1320
+ setVisible(visible: boolean): void;
1321
+ highlight(position: number): void;
1322
+ choiceAtPoint(x: number, y: number): number | undefined;
1323
+ clear(): void;
1324
+ dispose(): void;
1325
+ }
1326
+
1327
+ /**
1328
+ * Routes the avatar to a box-side or bubble-side presenter per line, the avatar
1329
+ * counterpart to {@link CompositeChrome} / {@link CompositeTextPresenter} /
1330
+ * {@link CompositeChoicePresenter}. It consults the SAME {@link MountRoute} the
1331
+ * other composites do, so a line's avatar lands on the same side as its chrome
1332
+ * and text. The non-routed presenter is cleared (`present(undefined)`), so only
1333
+ * one portrait shows at a time. The speaker-def + visibility verbs forward to
1334
+ * both (they are cheap and idempotent).
1335
+ */
1336
+
1337
+ declare class CompositeAvatarPresenter implements AvatarPresenter {
1338
+ private readonly box;
1339
+ private readonly bubble;
1340
+ private readonly routing;
1341
+ constructor(box: AvatarPresenter, bubble: AvatarPresenter, routing: MountRoute);
1342
+ mount(scene: Scene): void;
1343
+ setSpeaker(speaker: SpeakerDef | undefined): void;
1344
+ setExpression(expression: string | undefined): void;
1345
+ /** Forward an inline reveal marker to both (cheap + idempotent, like
1346
+ * setExpression — the inactive side's setExpression no-ops with no speaker). */
1347
+ marker(marker: MarkerToken): void;
1348
+ setSpeaking(speaking: boolean): void;
1349
+ present(line: PresentedLine | undefined): void;
1350
+ setVisible(visible: boolean): void;
1351
+ update(dt: number): void;
1352
+ dispose(): void;
1353
+ }
1354
+
1355
+ /**
1356
+ * Portrait avatar: a sprite that sits beside the box on the left or right.
1357
+ * Expression variants are just different textures (from the speaker's
1358
+ * `avatar.expressions` map); "speaking" adds a gentle talk bob. Textures must
1359
+ * be preloaded by the host scene — the presenter only resolves handles.
1360
+ */
1361
+
1362
+ interface PortraitPresenterConfig {
1363
+ readonly layer: string;
1364
+ /** Centre X for a left-side portrait (screen px). */
1365
+ readonly leftX: number;
1366
+ /** Centre X for a right-side portrait (screen px). */
1367
+ readonly rightX: number;
1368
+ /** Centre Y (screen px). */
1369
+ readonly y: number;
1370
+ /** Uniform sprite scale. */
1371
+ readonly scale: number;
1372
+ }
1373
+ declare class PortraitPresenter implements AvatarPresenter {
1374
+ private readonly cfg;
1375
+ private scene;
1376
+ private entity;
1377
+ private sprite;
1378
+ private transform;
1379
+ private current;
1380
+ private speaking;
1381
+ private bobMs;
1382
+ private baseX;
1383
+ private baseY;
1384
+ /** Host-hidden gate — a cutscene hides the portrait with the rest of the
1385
+ * UI. Composes with "is a portrait speaker active": shown = current && !hidden. */
1386
+ private hidden;
1387
+ private readonly handles;
1388
+ constructor(cfg: PortraitPresenterConfig);
1389
+ mount(scene: Scene): void;
1390
+ setSpeaker(speaker: SpeakerDef | undefined): void;
1391
+ /** Host-hidden gate — hide the portrait during a cutscene, restore on
1392
+ * show. Composes with the active-speaker state, so showing again only
1393
+ * re-reveals the portrait if a portrait speaker is still current. */
1394
+ setVisible(visible: boolean): void;
1395
+ private applyVisibility;
1396
+ setExpression(expression: string | undefined): void;
1397
+ /** Interpret a mid-line `[expression=…/]` reveal marker as a face swap (the
1398
+ * Session name-matches nothing — the presenter owns the convention). */
1399
+ marker(marker: MarkerToken): void;
1400
+ setSpeaking(speaking: boolean): void;
1401
+ update(dt: number): void;
1402
+ dispose(): void;
1403
+ private ensureSprite;
1404
+ private applyTexture;
1405
+ private handle;
1406
+ private hide;
1407
+ }
1408
+
1409
+ /**
1410
+ * Scene-figure avatar: instead of a portrait, the "avatar" is an entity that
1411
+ * already exists in the world (an NPC standing in the shop, say). The speaker's
1412
+ * `avatar.ref` is that entity's name. This presenter is the communication seam
1413
+ * the design calls for — it doesn't know about your character system, so it
1414
+ * takes callbacks to translate expression/speaking into whatever the figure
1415
+ * supports (swap an AnimatedSprite clip, tint, toggle a talk loop). Out of the
1416
+ * box it does a subtle talk bob on the figure's Transform.
1417
+ */
1418
+
1419
+ interface SceneFigurePresenterConfig {
1420
+ /** Map a script expression id onto your character system. */
1421
+ readonly onExpression?: (figure: Entity, expression: string | undefined) => void;
1422
+ /** Toggle a talk animation / mouth flap. */
1423
+ readonly onSpeaking?: (figure: Entity, speaking: boolean) => void;
1424
+ /** Apply the built-in talk bob (default true). */
1425
+ readonly bob?: boolean;
1426
+ }
1427
+ declare class SceneFigurePresenter implements AvatarPresenter {
1428
+ private readonly cfg;
1429
+ private scene;
1430
+ private figure;
1431
+ private actor;
1432
+ private transform;
1433
+ private speaking;
1434
+ private bobMs;
1435
+ /** Bob displacement currently applied to the figure's Transform. The bob is
1436
+ * a *relative* offset (delta-translated each frame), so external movement —
1437
+ * an NPC walking mid-line — is preserved instead of being pinned back to a
1438
+ * position captured at setSpeaker time. */
1439
+ private bobOffset;
1440
+ constructor(cfg?: SceneFigurePresenterConfig);
1441
+ mount(scene: Scene): void;
1442
+ setSpeaker(speaker: SpeakerDef | undefined): void;
1443
+ setExpression(expression: string | undefined): void;
1444
+ /** Mid-line `[expression=…/]` reveal marker → the figure's own expression
1445
+ * (actor or the `onExpression` callback). The Session name-matches nothing. */
1446
+ marker(marker: MarkerToken): void;
1447
+ setSpeaking(speaking: boolean): void;
1448
+ update(dt: number): void;
1449
+ dispose(): void;
1450
+ /** Remove only the residual bob displacement — never teleport to a captured
1451
+ * base, which would undo legitimate movement since speaking began. */
1452
+ private releaseBob;
1453
+ }
1454
+
1455
+ /**
1456
+ * InBoxAvatarPresenter — the reference **line-driven, reflowing in-box avatar**.
1457
+ * It is built ONLY from the documented presenter contract:
1458
+ *
1459
+ * - {@link AvatarChannel.present} gives it the line, so it reads `meta.portrait`
1460
+ * (texture key), `meta.side` (`left`/`right`, default left), and `meta.presence`
1461
+ * (set `false` to speak from off-screen — portrait hidden, no inset);
1462
+ * - {@link BoxLayout.setInset} reserves a column, so the box body text **reflows**
1463
+ * around the portrait, and {@link BoxLayout.frameRect} places the sprite.
1464
+ *
1465
+ * It needs NO addon internals — that's the point: it doubles as the proof the
1466
+ * contract is sufficient to write a custom presenter (if building it ever needed
1467
+ * an internal, the contract — not the presenter — would be wrong). It is
1468
+ * opt-in: wire it through `createBoxDialogue(theme, { avatar })`, which hands
1469
+ * it the box's shared layout owner. With no avatar wired (or no `meta.portrait`),
1470
+ * behavior is unchanged.
1471
+ *
1472
+ * Portrait textures must be **preloaded** by the host (the presenter only
1473
+ * resolves `meta.portrait` to a handle), like `PortraitPresenter`.
1474
+ */
1475
+
1476
+ interface InBoxAvatarConfig {
1477
+ /** Render layer (screen-space) — e.g. `DIALOGUE_LAYER_AVATAR`, which sits
1478
+ * between the frame and the text so the portrait tucks behind the box edge. */
1479
+ readonly layer: string;
1480
+ /** Width (px) of the reserved avatar column; the body text reflows past it. */
1481
+ readonly width: number;
1482
+ /** Gap (px) between the avatar column and the reflowed text. Default 8. */
1483
+ readonly gap?: number;
1484
+ /** Uniform sprite scale (textures must be preloaded by the host). Default 1. */
1485
+ readonly scale?: number;
1486
+ /** Optional rounded-rect panel drawn behind the portrait (a framed look),
1487
+ * sized to the {@link width} column. Omit for a bare sprite. */
1488
+ readonly background?: {
1489
+ readonly color: number;
1490
+ readonly alpha?: number;
1491
+ /** Corner radius (px). Default 8. */
1492
+ readonly radius?: number;
1493
+ };
1494
+ /** Vertical alignment in the box: `top` (level with the body text) or
1495
+ * `center` (default — centred in the frame, so it sinks in a grown choice box). */
1496
+ readonly align?: "top" | "center";
1497
+ }
1498
+ declare class InBoxAvatarPresenter implements AvatarPresenter {
1499
+ private readonly layout;
1500
+ private readonly cfg;
1501
+ private readonly insetKey;
1502
+ private scene;
1503
+ private entity;
1504
+ private sprite;
1505
+ private transform;
1506
+ /** Optional background panel (behind the portrait), its own entity so it draws
1507
+ * under the sprite on the same layer. */
1508
+ private bgEntity;
1509
+ private bg;
1510
+ private bgTransform;
1511
+ private side;
1512
+ /** A portrait is up for the current line (from `meta.portrait` + presence). */
1513
+ private shown;
1514
+ /** Host-hidden gate (a cutscene hides the avatar with the rest of the UI). */
1515
+ private hidden;
1516
+ private readonly handles;
1517
+ constructor(layout: BoxLayout, cfg: InBoxAvatarConfig);
1518
+ mount(scene: Scene): void;
1519
+ setSpeaker(): void;
1520
+ setExpression(): void;
1521
+ setSpeaking(): void;
1522
+ /** Routes a mid-line `[expression=…/]` marker to its own setExpression (inert
1523
+ * here — this avatar is portrait-by-`meta`, not expression-mapped — so it's
1524
+ * the uniform contract, not a visible face swap). */
1525
+ marker(marker: MarkerToken): void;
1526
+ /** Read the line's `meta` to show/hide the portrait and reserve (or clear) the
1527
+ * text-reflow inset. Called before the body text presents, so the text wraps
1528
+ * to the narrowed region. */
1529
+ present(line: PresentedLine | undefined): void;
1530
+ /** Host-hidden gate — hide with a cutscene, restore on show (only if a
1531
+ * portrait is still current). */
1532
+ setVisible(visible: boolean): void;
1533
+ update(): void;
1534
+ dispose(): void;
1535
+ /** Centre the portrait (+ its panel) in its reserved column, inset by the box
1536
+ * padding so it sits inside the border like the text — at the frame's current
1537
+ * rect, so it follows `meta.position` and a grown choice panel. */
1538
+ private place;
1539
+ private applyVisibility;
1540
+ private ensureSprite;
1541
+ private applyTexture;
1542
+ private handle;
1543
+ }
1544
+
1545
+ /**
1546
+ * BubbleAvatarPresenter — the reference **line-driven portrait INSIDE the speech
1547
+ * bubble**, the bubble counterpart to {@link InBoxAvatarPresenter}. Built only
1548
+ * from the documented contract: {@link AvatarChannel.present} gives it the line
1549
+ * (so it reads `meta.portrait` / `meta.side` / `meta.presence`), and it reserves
1550
+ * a portrait column on the shared {@link BubbleLayout} (`setPortraitInset`) — so
1551
+ * the bubble **grows** to contain the portrait and its body text **reflows** past
1552
+ * it, and the whole thing follows the speaker's actor.
1553
+ *
1554
+ * Portrait textures must be **preloaded** by the host. Wire it through
1555
+ * `createMixedDialogue(theme, { avatar: { bubble } })`.
1556
+ */
1557
+
1558
+ interface BubbleAvatarConfig {
1559
+ /** World-space render layer (same as the bubble). */
1560
+ readonly layer: string;
1561
+ /** Portrait column size (px) reserved beside the bubble. */
1562
+ readonly size: number;
1563
+ /** Gap (px) between the portrait and the bubble edge. Default 8. */
1564
+ readonly gap?: number;
1565
+ /** Uniform sprite scale. Default 1. */
1566
+ readonly scale?: number;
1567
+ /** Optional rounded-rect panel behind the portrait. */
1568
+ readonly background?: {
1569
+ readonly color: number;
1570
+ readonly alpha?: number;
1571
+ readonly radius?: number;
1572
+ };
1573
+ /** Vertical alignment in the bubble: `top` (level with the body text) or
1574
+ * `center` (default). */
1575
+ readonly align?: "top" | "center";
1576
+ }
1577
+ declare class BubbleAvatarPresenter implements AvatarPresenter {
1578
+ private readonly layout;
1579
+ private readonly cfg;
1580
+ private scene;
1581
+ private entity;
1582
+ private sprite;
1583
+ private transform;
1584
+ private bgEntity;
1585
+ private bg;
1586
+ private bgTransform;
1587
+ private side;
1588
+ private speakerId;
1589
+ private shown;
1590
+ private hidden;
1591
+ private readonly handles;
1592
+ constructor(layout: BubbleLayout, cfg: BubbleAvatarConfig);
1593
+ mount(scene: Scene): void;
1594
+ setSpeaker(): void;
1595
+ setExpression(): void;
1596
+ setSpeaking(): void;
1597
+ /** Uniform marker contract: routes `[expression=…/]` to its own setExpression
1598
+ * (inert here — this avatar is portrait-by-`meta`). */
1599
+ marker(marker: MarkerToken): void;
1600
+ present(line: PresentedLine | undefined): void;
1601
+ setVisible(visible: boolean): void;
1602
+ update(): void;
1603
+ dispose(): void;
1604
+ /** Place the portrait in its reserved column INSIDE the active bubble (say
1605
+ * bubble or choice panel), vertically centred on the body, tracking the
1606
+ * speaker's (moving) anchor. */
1607
+ private follow;
1608
+ private applyVisibility;
1609
+ private ensureSprite;
1610
+ private applyTexture;
1611
+ private handle;
1612
+ }
1613
+
1614
+ /**
1615
+ * A component you drop on any world entity to make it a dialogue *actor*: it
1616
+ * self-registers under a logical `speaker` id (see {@link ActorRegistry}) so
1617
+ * presenters can find "where is whoever is speaking" without an external map.
1618
+ * It owns the per-entity translation of dialogue intent — expression + speaking
1619
+ * — into whatever the entity's character system supports (swap an animation
1620
+ * clip, tint, toggle a talk loop) via callbacks, and exposes a head/anchor
1621
+ * point for diegetic bubbles to position against.
1622
+ */
1623
+
1624
+ interface DialogueActorOptions {
1625
+ /** Logical speaker id this entity answers to (matches the script). */
1626
+ readonly speaker: string;
1627
+ /** Offset from the entity transform to the bubble anchor (head), in px. */
1628
+ readonly anchor?: {
1629
+ readonly x: number;
1630
+ readonly y: number;
1631
+ };
1632
+ /** Map a script expression id onto this entity's character system. */
1633
+ readonly onExpression?: (entity: Entity, expression: string | undefined) => void;
1634
+ /** Toggle a talk animation / mouth flap. */
1635
+ readonly onSpeaking?: (entity: Entity, speaking: boolean) => void;
1636
+ }
1637
+ declare class DialogueActor extends Component {
1638
+ private readonly opts;
1639
+ constructor(opts: DialogueActorOptions);
1640
+ get speaker(): string;
1641
+ onAdd(): void;
1642
+ onDestroy(): void;
1643
+ /** Bubble anchor in world space: the entity position plus the configured offset. */
1644
+ anchorWorld(): {
1645
+ x: number;
1646
+ y: number;
1647
+ };
1648
+ setExpression(expression: string | undefined): void;
1649
+ setSpeaking(speaking: boolean): void;
1650
+ }
1651
+
1652
+ /**
1653
+ * Scene-scoped speaker → live entity index. A {@link DialogueActor} self-
1654
+ * registers under its logical speaker id; presenters resolve a speaker to a
1655
+ * world entity through here instead of carrying an external map. Scripts always
1656
+ * speak in *logical* ids (invariant) — instance selection happens here, never
1657
+ * in the script.
1658
+ *
1659
+ * The registry is keyed off the `Scene` via a WeakMap, so it needs no service
1660
+ * registration and is torn down with the scene. Multiple live entities for one
1661
+ * speaker id is unsupported: last registration wins.
1662
+ */
1663
+
1664
+ declare class ActorRegistry {
1665
+ private readonly actors;
1666
+ register(speaker: string, actor: DialogueActor): void;
1667
+ unregister(speaker: string, actor: DialogueActor): void;
1668
+ resolve(speaker: string | undefined): DialogueActor | undefined;
1669
+ }
1670
+ /** The (lazily-created) registry for a scene. Shared by actors + presenters. */
1671
+ declare function actorRegistryFor(scene: Scene): ActorRegistry;
1672
+
1673
+ /**
1674
+ * defaultTheme — a zero-config, zero-asset {@link DialogueTheme}.
1675
+ *
1676
+ * Renders entirely with Graphics chrome (rounded rectangles + strokes) and
1677
+ * canvas text (SplitText/Text). No bitmap fonts, no textures, no bundled
1678
+ * assets — so `createBoxDialogue()` / `createBubbleDialogue(undefined, opts)`
1679
+ * work with no caller-supplied theme.
1680
+ *
1681
+ * Returns a fresh object each call so callers can spread-and-tweak without
1682
+ * mutating a shared singleton:
1683
+ *
1684
+ * ```ts
1685
+ * const theme = { ...defaultTheme(), textColor: 0xff0000 };
1686
+ * ```
1687
+ *
1688
+ * The `box` is viewport-relative (margins + height), so it's a full-width bottom
1689
+ * bar at ANY virtual resolution with no override. Bitmap fonts (`bitmapFont`) and
1690
+ * textured nine-slice chrome (the `textured` field) are OPT-IN re-theming paths,
1691
+ * intentionally absent here.
1692
+ */
1693
+ declare function defaultTheme(): DialogueTheme;
1694
+
1695
+ /**
1696
+ * `createBoxDialogue(theme)` is the easy on-ramp: hand it one {@link DialogueTheme}
1697
+ * and get back the wired presenter bundle for a classic bottom-of-screen box
1698
+ * (frame + nameplate + caret, a typewriter body, a vertical choice list). Spread
1699
+ * it into a controller and override any one piece:
1700
+ *
1701
+ * new DialogueController({ ...createBoxDialogue(theme), avatar, storage });
1702
+ *
1703
+ * It only assembles configs + presenters from the theme — no scene, no input —
1704
+ * so the host stays in charge of lifecycle. All four box presenters share ONE
1705
+ * {@link BoxLayout} so the frame, nameplate, body text, and choice rows move and
1706
+ * grow as one panel (per-line `meta.position`, choice-grow, avatar reflow).
1707
+ *
1708
+ * `theme` defaults to {@link defaultTheme} so a zero-config call works out of
1709
+ * the box (Graphics chrome + canvas text, no bundled assets).
1710
+ */
1711
+
1712
+ interface BoxDialogueOptions {
1713
+ /**
1714
+ * Build an avatar presenter wired to the box's shared {@link BoxLayout} — so
1715
+ * a line-driven, reflowing in-box avatar (the reference `InBoxAvatarPresenter`)
1716
+ * can reserve a text-reflow inset on it. Receives the layout the box
1717
+ * chrome/text/choices share. Omit for no avatar (the default).
1718
+ *
1719
+ * createBoxDialogue(theme, {
1720
+ * avatar: (layout) =>
1721
+ * new InBoxAvatarPresenter(layout, { layer: DIALOGUE_LAYER_AVATAR, width: 96 }),
1722
+ * })
1723
+ */
1724
+ readonly avatar?: (layout: BoxLayout) => AvatarPresenter;
1725
+ }
1726
+ declare function createBoxDialogue(theme?: DialogueTheme, opts?: BoxDialogueOptions): DialogueBundle;
1727
+
1728
+ /**
1729
+ * `createBubbleDialogue(theme, opts)` wires a diegetic variant: the body text
1730
+ * floats in a world-space speech bubble over the speaking NPC (resolved via its
1731
+ * {@link DialogueActor}), while choices float in their own bubble panel over the
1732
+ * actor. Reuses the {@link DialogueTheme} for colours/fonts so a game's box and
1733
+ * bubble dialogues look consistent.
1734
+ *
1735
+ * new DialogueController({ ...createBubbleDialogue(theme, { worldLayer }), avatar });
1736
+ *
1737
+ * The actors must already carry a {@link DialogueActor} (speaker id + head
1738
+ * anchor); the bubble follows that anchor every frame. Register exactly ONE
1739
+ * actor per speaker id — the bubble chrome and the bubble text each resolve the
1740
+ * speaker independently, so two actors sharing an id would let them track
1741
+ * different entities and the text would drift off the bubble.
1742
+ *
1743
+ * `theme` defaults to {@link defaultTheme} so a zero-config call works out of
1744
+ * the box (Graphics chrome + canvas text, no bundled assets).
1745
+ */
1746
+
1747
+ interface BubbleGeometry {
1748
+ /** Snuggest width; the bubble widens to its text up to {@link maxWidth}. */
1749
+ readonly minWidth: number;
1750
+ /** Widest the bubble grows before its text wraps to more lines. */
1751
+ readonly maxWidth: number;
1752
+ /** Minimum height; grows to fit wrapped text once `maxWidth` is reached. */
1753
+ readonly height: number;
1754
+ readonly padding: number;
1755
+ /** Gap between the actor's head anchor and the bubble's bottom edge. */
1756
+ readonly offsetY: number;
1757
+ /** Tail (pointer) height. */
1758
+ readonly tail: number;
1759
+ }
1760
+ declare const DEFAULT_BUBBLE: BubbleGeometry;
1761
+ interface BubbleDialogueOptions {
1762
+ /** World-space render layer the bubble + text draw into. */
1763
+ readonly worldLayer: string;
1764
+ readonly bubble?: Partial<BubbleGeometry>;
1765
+ /**
1766
+ * Where a bubble anchors when its speaker has no live actor and no last-known
1767
+ * position (a never-seen speaker / a narrator in a pure-bubble bundle).
1768
+ * Defaults to the world origin; point it at your camera centre so a
1769
+ * speakerless line lands on screen. A despawned actor uses its last-known
1770
+ * position regardless. Shared by the chrome, text, and choice presenters.
1771
+ */
1772
+ readonly fallbackAnchor?: () => {
1773
+ x: number;
1774
+ y: number;
1775
+ };
1776
+ /** Build an avatar presenter wired to the bubble's shared {@link BubbleLayout}
1777
+ * — e.g. the reference `BubbleAvatarPresenter`, a line-driven portrait beside
1778
+ * the bubble. Receives the layout the bubble chrome/text/choices share. */
1779
+ readonly avatar?: (layout: BubbleLayout) => AvatarPresenter;
1780
+ }
1781
+ declare function createBubbleDialogue(theme: DialogueTheme | undefined, opts: BubbleDialogueOptions): DialogueBundle;
1782
+
1783
+ /**
1784
+ * `createMixedDialogue` composes the box and bubble factories so one
1785
+ * conversation can place each line — and each choice — in either presentation,
1786
+ * chosen per step by its `view` hint ("box" — default — vs "bubble"). Text,
1787
+ * chrome, choices, and the avatar all route the same way, so a box choice keeps
1788
+ * the framed bottom list while a bubble choice gets its own panel over the actor.
1789
+ *
1790
+ * `theme` defaults to {@link defaultTheme} so a zero-config call works out of
1791
+ * the box (Graphics chrome + canvas text, no bundled assets).
1792
+ */
1793
+
1794
+ interface MixedDialogueOptions extends Omit<BubbleDialogueOptions, "avatar"> {
1795
+ /**
1796
+ * Override the box-vs-bubble routing policy for this bundle. The default is
1797
+ * speaker-aware (narrator → box; explicit `view` wins; else a registered
1798
+ * actor → bubble, otherwise box). Supply a route to key off anything on the
1799
+ * line — e.g. `(line) => line?.speaker?.id === "boss" ? "bubble" : "box"`.
1800
+ * All composites (and the avatar) consult this one route, so chrome, text,
1801
+ * choices, and avatar always agree per line.
1802
+ */
1803
+ readonly route?: CompositeRoute;
1804
+ /**
1805
+ * Wire avatar presenters per side. `box` gets the box's {@link BoxLayout} (an
1806
+ * in-box reflowing avatar); `bubble` gets the bubble's {@link BubbleLayout} (a
1807
+ * portrait beside the bubble). With both, a {@link CompositeAvatarPresenter}
1808
+ * routes each line to the matching side; with one, only that side shows.
1809
+ */
1810
+ readonly avatar?: {
1811
+ readonly box?: BoxDialogueOptions["avatar"];
1812
+ readonly bubble?: BubbleDialogueOptions["avatar"];
1813
+ };
1814
+ }
1815
+ declare function createMixedDialogue(theme: DialogueTheme | undefined, opts: MixedDialogueOptions): DialogueBundle;
1816
+
1817
+ export { ActorRegistry, type AnchorPoint, AvatarPresenter, type BoxBounds, type BoxDialogueOptions, BoxLayout, type BoxLayoutConfig, type BoxPosition, BoxTextView, BubbleAnchorResolver, type BubbleAvatarConfig, BubbleAvatarPresenter, type BubbleChoiceConfig, BubbleChoicePresenter, BubbleChrome, type BubbleChromeConfig, type BubbleDialogueOptions, type BubbleGeometry, BubbleLayout, type BubbleLayoutConfig, BubbleTextView, CHROME_STYLE_DEFAULT, CHROME_STYLE_NONE, type CaretTheme, type ChoiceListConfig, ChoiceListPresenter, ChoicePresenter, type ChoiceRowRect, ChromePresenter, type ChromeStyle, CompositeAvatarPresenter, CompositeChoicePresenter, CompositeChrome, type CompositeRoute, CompositeTextPresenter, DEFAULT_BUBBLE, DEFAULT_CARET_BLINK_MS, DEFAULT_CARET_SIZE, DEFAULT_CHOICE_GAP, DEFAULT_TAIL_LEAN, DIALOGUE_LAYERS, DIALOGUE_LAYER_AVATAR, DIALOGUE_LAYER_FRAME, DIALOGUE_LAYER_TEXT, DiagnosticSink, DialogueActor, type DialogueActorOptions, DialogueChrome, type DialogueChromeConfig, type DialogueTextConfig, DialogueTextView, type DialogueTheme, type EffectOutput, type FontConfig, type InBoxAvatarConfig, InBoxAvatarPresenter, type MixedDialogueOptions, type MountRoute, type NineSliceFrame, type NineSliceInsets, PortraitPresenter, type PortraitPresenterConfig, type RadialChoiceConfig, RadialChoicePresenter, type Rect, SceneFigurePresenter, type SceneFigurePresenterConfig, type TextInset, TextPresenter, actorRegistryFor, createBoxDialogue, createBubbleDialogue, createMixedDialogue, defaultTheme, effectDrivesTint, evaluateEffect, fixedRoute, makeDefaultRoute, routeWithActor, stackChoiceRows };