import Phaser from 'phaser'; import { UI, chunkyButton, font, panel } from './style'; import { drunkify, typedLength, wobbleAt, type RngLike } from './typo'; // Bottom-third dialogue for the door's sobriety test and the floor's cut-off // chats. Deliberately ignorant of who is talking — the caller supplies a speaker // tag, a line, and up to four things you can say back. const PANEL_X = 320; const PANEL_Y = 300; const PANEL_W = 600; const PANEL_H = 96; const PAD = 10; const BODY_SIZE = 10; const CHOICE_H = 16; const CHOICE_GAP = 8; const CHOICE_MAX_W = 140; export interface DialogueChoice { id: string; label: string; } export interface DialogueOptions { speaker: string; text: string; choices?: readonly DialogueChoice[]; /** 0..1 — drives drunkify() plus per-character wobble. */ drunkenness?: number; /** Required when drunkenness > 0; a deterministic stand-in is used if absent. */ rng?: RngLike; charMs?: number; depth?: number; sfx?: { typeTick(): void }; onChoice?: (id: string) => void; onComplete?: () => void; } interface Glyph { ch: string; /** Index into the mangled source string — what the reveal count is measured in. */ index: number; x: number; y: number; } /** LCG stand-in so a caller who forgot the rng gets slurring, not a crashed night. */ function fallbackRng(): RngLike { let s = 0x2f6e2b1; return { next: (): number => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0x100000000; }, }; } /** * Greedy word wrap that keeps every glyph's source index, which Phaser's own * wordWrap throws away. The drunk path needs the mapping to reveal characters in * source order while drawing them in wrapped positions. */ function layoutGlyphs(text: string, maxCols: number, charW: number, lineH: number, x0: number, y0: number): Glyph[] { const out: Glyph[] = []; let line = 0; let col = 0; let i = 0; const push = (ch: string, index: number): void => { out.push({ ch, index, x: x0 + col * charW, y: y0 + line * lineH }); col++; }; while (i < text.length) { const ch = text.charAt(i); if (ch === '\n') { line++; col = 0; i++; continue; } if (ch === ' ') { if (col > 0) push(ch, i); i++; continue; } let end = i; while (end < text.length && text.charAt(end) !== ' ' && text.charAt(end) !== '\n') end++; if (col > 0 && col + (end - i) > maxCols) { // The space that pushed us over hangs off the end of the line — drop it. if (out.at(-1)?.ch === ' ') out.pop(); line++; col = 0; } for (let k = i; k < end; k++) { if (col >= maxCols) { line++; col = 0; } push(text.charAt(k), k); } i = end; } return out; } export class DialogueBox { private readonly root: Phaser.GameObjects.Container; private readonly body: Phaser.GameObjects.Text | null = null; private readonly glyphs: Glyph[] = []; private readonly glyphTexts: Phaser.GameObjects.Text[] = []; private readonly buttons: Phaser.GameObjects.Container[] = []; private readonly full: string; private readonly charMs: number; private readonly drunkenness: number; private readonly sfx: { typeTick(): void } | undefined; private readonly onChoice: ((id: string) => void) | undefined; private readonly onComplete: (() => void) | undefined; private elapsed = 0; private phase = 0; private visible = 0; private finished = false; private completed = false; private locked = false; private destroyed = false; constructor(scene: Phaser.Scene, opts: DialogueOptions) { this.charMs = opts.charMs ?? 28; this.drunkenness = Math.max(0, Math.min(1, opts.drunkenness ?? 0)); this.sfx = opts.sfx; this.onChoice = opts.onChoice; this.onComplete = opts.onComplete; // Mangle once at construction; per-frame drunkify would shimmer the line. this.full = this.drunkenness > 0 ? drunkify(opts.text, { drunkenness: this.drunkenness, rng: opts.rng ?? fallbackRng() }) : opts.text; this.root = panel(scene, PANEL_X, PANEL_Y, PANEL_W, PANEL_H); this.root.setDepth(opts.depth ?? 900); const tag = scene.add.text(0, 0, opts.speaker, font(9, UI.ink)).setOrigin(0, 0.5); const tagBg = scene.add .rectangle(-PANEL_W / 2 + PAD - 4, -PANEL_H / 2, tag.width + 10, 13, UI.kebabAmber) .setOrigin(0, 0.5); tag.setPosition(-PANEL_W / 2 + PAD + 1, -PANEL_H / 2); this.root.add([tagBg, tag]); const bodyX = -PANEL_W / 2 + PAD; const bodyY = -PANEL_H / 2 + 14; const wrapPx = PANEL_W - PAD * 2; if (this.drunkenness > 0) { const probe = scene.add.text(0, 0, 'M', font(BODY_SIZE, UI.paper)); const charW = probe.width > 0 ? probe.width : BODY_SIZE * 0.6; const lineH = Math.max(probe.height, BODY_SIZE + 2); probe.destroy(); const cols = Math.max(1, Math.floor(wrapPx / charW)); this.glyphs = layoutGlyphs(this.full, cols, charW, lineH, bodyX, bodyY); for (const g of this.glyphs) { const t = scene.add.text(g.x, g.y, g.ch, font(BODY_SIZE, UI.paper)).setOrigin(0, 0).setVisible(false); this.glyphTexts.push(t); this.root.add(t); } } else { this.body = scene.add .text(bodyX, bodyY, '', { ...font(BODY_SIZE, UI.paper), wordWrap: { width: wrapPx } }) .setOrigin(0, 0); this.root.add(this.body); } this.buildChoices(scene, opts.choices ?? []); } private buildChoices(scene: Phaser.Scene, choices: readonly DialogueChoice[]): void { if (choices.length === 0) return; const n = Math.min(choices.length, 4); const avail = PANEL_W - PAD * 2; const w = Math.min(CHOICE_MAX_W, (avail - CHOICE_GAP * (n - 1)) / n); const rowW = w * n + CHOICE_GAP * (n - 1); const y = PANEL_H / 2 - CHOICE_H / 2 - 6; for (let i = 0; i < n; i++) { const choice = choices[i]; if (choice === undefined) continue; const x = -rowW / 2 + w / 2 + i * (w + CHOICE_GAP); const btn = chunkyButton(scene, x, y, choice.label, { w, h: CHOICE_H, fill: UI.grime, textColour: UI.neonGreen, fontSize: 8, onClick: () => this.pick(choice.id), }); btn.setVisible(false); this.buttons.push(btn); this.root.add(btn); } } private pick(id: string): void { if (this.destroyed || this.locked) return; this.locked = true; for (const btn of this.buttons) { btn.setAlpha(0.55); for (const child of btn.list) child.disableInteractive(); } this.onChoice?.(id); } update(deltaMs: number): void { if (this.destroyed) return; // Both accumulators below are permanent state, so one non-finite delta would // stall the reveal for the rest of the night with the buttons never shown. // typo.ts clamps its own inputs too, but that only stops the NaN spreading — // dropping the bad frame here is what keeps `elapsed` usable. if (!Number.isFinite(deltaMs)) return; if (this.drunkenness > 0) this.phase += deltaMs * 0.006; if (!this.finished) { this.elapsed += deltaMs; this.reveal(typedLength(this.full, this.elapsed, this.charMs, this.drunkenness)); } if (this.drunkenness > 0) { this.glyphs.forEach((g, k) => { const t = this.glyphTexts[k]; if (t?.visible !== true) return; t.y = g.y + wobbleAt(g.index, this.drunkenness, this.phase); }); } } skip(): void { if (this.destroyed) return; this.elapsed = this.full.length * this.charMs * 3; this.reveal(this.full.length); } private reveal(next: number): void { if (next > this.visible) { // One tick per burst, and never for whitespace — otherwise it machine-guns. if (/\S/.test(this.full.slice(this.visible, next))) this.sfx?.typeTick(); this.visible = next; this.paint(); } if (this.visible >= this.full.length && !this.finished) { this.finished = true; for (const btn of this.buttons) btn.setVisible(true); if (!this.completed) { this.completed = true; this.onComplete?.(); } } } private paint(): void { if (this.body !== null) { this.body.setText(this.full.slice(0, this.visible)); return; } this.glyphs.forEach((g, k) => { this.glyphTexts[k]?.setVisible(g.index < this.visible); }); } get done(): boolean { return this.finished; } // Guards every public entry point: Phaser's Text.preDestroy hands the canvas // back to CanvasPool without nulling it, so a stray update()/skip() would // setText() onto a canvas the pool may have already re-issued to another widget. destroy(): void { if (this.destroyed) return; this.destroyed = true; this.root.destroy(); this.glyphs.length = 0; this.glyphTexts.length = 0; this.buttons.length = 0; } }