not-tonight/src/ui/DialogueBox.ts
type-two 2eb3c94ccd LANE-JUICE: audio engine, SFX set, five UI widgets, JuiceDemoScene
Audio (src/audio/), raw WebAudio, no samples or libraries:
- TechnoEngine: 128 BPM four-on-the-floor kick, off-beat hats, 2-bar 16th
  bassline, 8-bar pad. Look-ahead scheduler (25ms timer, 100ms horizon) is the
  beat authority — emits beat:tick with the SCHEDULED audio time, so consumers
  can compare honestly. Replaces core/StubBeatClock at integration; same event.
  Stale beats are resynced to the next downbeat rather than caught up: a
  throttled/hidden tab otherwise dumps a 64-beat backlog onto one sample.
  Music runs through the location lowpass; SFX bypass it and stay crisp.
- Sfx: 10 procedural one-shots + rain bed, one shared noise buffer, voices
  self-disconnect on ended so a six-hour night doesn't accumulate nodes.

UI (src/ui/), reusable Phaser containers, no door/floor imports:
- Stamp (the signature interaction), Phone (Dazza thread + phoneTheatre),
  DialogueBox (typed-out, drunk-typo render), Clicker (digit roll),
  MeterHud (neon vibe / crowd-temp aggro / hype badge / 3 heat slots).
- JuiceDemoScene exercises every widget and SFX, with a log-mapped cutoff
  slider and a DOOR/FLOOR toggle that routes through the bus.

Verified in-browser: the door->floor sweep ramps 250Hz->18kHz over 600ms,
monotonic and exponential (halfway lands ~2.1kHz, not the ~9kHz a linear ramp
would give) — that late bloom is what sells the door opening. Under a real
hidden tab, 52 consecutive beats scheduled with zero in the past, 75-98ms lead,
max one beat per wake.

main.ts touched to route J / #juice to the demo — outside lane ownership,
flagged for sign-off in LANEHANDOVER.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:52:43 +10:00

289 lines
8.7 KiB
TypeScript

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;
}
}