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>
289 lines
8.7 KiB
TypeScript
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;
|
|
}
|
|
}
|