not-tonight/src/ui/JuiceDemoScene.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

565 lines
20 KiB
TypeScript

import Phaser from 'phaser';
import { EventBus } from '../core/EventBus';
import { GameClock } from '../core/GameClock';
import { SeededRNG } from '../core/SeededRNG';
import { Meters, freshNightState } from '../core/meters';
import { Sfx, type SfxName } from '../audio/Sfx';
import { TechnoEngine } from '../audio/TechnoEngine';
import { DAZZA_TEXTS } from '../data/strings/dazza';
import type { DressCodeRule } from '../data/types';
import { Clicker } from './Clicker';
import { DialogueBox } from './DialogueBox';
import { MeterHud } from './MeterHud';
import { Phone } from './Phone';
import { stamp, type StampHandle } from './Stamp';
import { UI, chunkyButton, font } from './style';
import './juiceEvents';
const W = 640;
const H = 360;
const SEED = 4207;
// Log slider range. 200Hz is "bass through a fire door"; 20kHz is "standing in
// front of the rig". A linear map spends 90% of the track above 2kHz, where
// nothing audible happens.
const MIN_HZ = 200;
const MAX_HZ = 20000;
const HZ_RATIO = MAX_HZ / MIN_HZ;
const TRACK_X0 = 96;
const TRACK_X1 = 340;
const TRACK_Y = 314;
const BEAT_X = 320;
// Sized so the 1.42x downbeat flash still clears the help line at y 54 — the pad
// grows from its centre, so the resting size has to leave room for the punch.
const BEAT_Y = 26;
const BEAT_SIZE = 28;
/** Flash decay. Shorter than a beat at 128BPM (469ms) so beats stay distinct. */
const FLASH_MS = 170;
const PATRON_X = 300;
const PATRON_Y = 200;
/** Reviewers mash the verdict buttons; keep a readable stack of overprints, not 400. */
const MAX_MARKS = 5;
const SFX_BUTTONS: ReadonlyArray<readonly [SfxName, string]> = [
['stampSlam', 'STAMP SLAM'],
['stampInk', 'STAMP INK'],
['clickerClunk', 'CLICKER'],
['doorBang', 'DOOR BANG'],
['radioStatic', 'RADIO HISS'],
['radioChirp', 'RADIO CHIRP'],
['phoneBuzz', 'PHONE BUZZ'],
['ropeUnhook', 'ROPE OFF'],
['denyCrowdOoh', 'CROWD OOOH'],
['typeTick', 'TYPE TICK'],
];
interface PendingBeat {
beatIndex: number;
audioTimeMs: number;
bar: boolean;
}
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v));
const hzFromT = (t: number): number => MIN_HZ * Math.pow(HZ_RATIO, clamp(t, 0, 1));
const tFromHz = (hz: number): number =>
clamp(Math.log(clamp(hz, MIN_HZ, MAX_HZ) / MIN_HZ) / Math.log(HZ_RATIO), 0, 1);
/** chunkyButton hands back a Container; this is the only way to relabel one. */
function labelOf(btn: Phaser.GameObjects.Container): Phaser.GameObjects.Text | undefined {
return btn.list.find((o): o is Phaser.GameObjects.Text => o instanceof Phaser.GameObjects.Text);
}
/**
* LANE-JUICE proof scene: every widget and every SFX on one screen, plus a beat
* indicator that is honest about latency. Not shipped in the game — this is the
* thing a reviewer opens to check the juice lane actually works.
*/
export class JuiceDemoScene extends Phaser.Scene {
private bus!: EventBus;
private clock!: GameClock;
private rng!: SeededRNG;
private meters!: Meters;
private engine!: TechnoEngine;
private sfx!: Sfx;
private hud!: MeterHud;
private phone!: Phone;
private clicker!: Clicker;
private dialogue: DialogueBox | null = null;
private dialogueKill = false;
private statusText!: Phaser.GameObjects.Text;
private hzText!: Phaser.GameObjects.Text;
private beatPad!: Phaser.GameObjects.Rectangle;
private beatRing!: Phaser.GameObjects.Rectangle;
private handle!: Phaser.GameObjects.Rectangle;
private locButton!: Phaser.GameObjects.Container;
private rainButton!: Phaser.GameObjects.Container;
private patronRoot!: Phaser.GameObjects.Container;
private readonly stampMarks: StampHandle[] = [];
private readonly pendingBeats: PendingBeat[] = [];
private beatFlash = 0;
private beatFlashBar = false;
private lastBeat = -1;
private lastBar = -1;
private overrideHz: number | null = null;
private dragging = false;
private raining = false;
private dazzaIndex = 0;
private audioStarting = false;
constructor() {
super('JuiceDemo');
}
/**
* Phaser reuses the scene instance across restarts and only re-runs create(), so
* field initialisers fire once ever. Every per-run field has to be cleared here or
* it leaks into the next run: stale `pendingBeats` carry `audioTimeMs` from the old
* AudioContext, whose clock restarts near zero, and head-of-line-block drainBeats
* for as long as the previous run lasted.
*/
private resetRunState(): void {
this.pendingBeats.length = 0;
this.beatFlash = 0;
this.beatFlashBar = false;
this.lastBeat = -1;
this.lastBar = -1;
this.overrideHz = null;
this.dragging = false;
this.raining = false;
this.dazzaIndex = 0;
this.audioStarting = false;
this.dialogue = null;
this.dialogueKill = false;
// Shutdown already destroyed the objects these handles point at.
this.stampMarks.length = 0;
}
create(): void {
this.resetRunState();
this.bus = new EventBus();
this.rng = new SeededRNG(SEED);
this.clock = new GameClock(this.bus);
this.meters = new Meters(this.bus, freshNightState('theRoyal', 0, 80));
// The graph is built in the constructor, so Sfx can be wired now and simply
// stays silent until the context resumes. No post-unlock re-wiring needed.
this.engine = new TechnoEngine(this.bus);
this.sfx = new Sfx(this.engine.ctx, this.engine.sfxBus);
this.buildBackdrop();
this.buildBeatIndicator();
this.buildPatron();
this.buildStatus();
this.buildSfxColumn();
this.buildActionColumn();
this.buildAudioStrip();
this.hud = new MeterHud(this, this.bus, { x: 6, y: 6 });
this.clicker = new Clicker(this, this.bus, { x: 40, y: 320, sfx: this.sfx });
this.phone = new Phone(this, this.bus, { x: 600, y: 320, sfx: this.sfx });
this.bus.on('beat:tick', ({ beatIndex, audioTimeMs }) => {
this.pendingBeats.push({ beatIndex, audioTimeMs, bar: false });
});
// Fires straight after beat:tick for the same index, so the entry is there.
this.bus.on('beat:bar', ({ beatIndex }) => {
const b = this.pendingBeats.find((p) => p.beatIndex === beatIndex);
if (b) b.bar = true;
});
this.clock.start();
// Kick the HUD once so it shows the opening numbers instead of empty bars.
this.bus.emit('meters:delta', { vibe: 0 });
this.buildUnlockOverlay();
this.input.keyboard?.on('keydown-SPACE', () => this.dialogue?.skip());
this.events.once('shutdown', () => this.teardown());
}
// --- world ----------------------------------------------------------------
private buildBackdrop(): void {
this.add.rectangle(W / 2, H / 2, W, H, UI.ink);
this.add.rectangle(W / 2, 62, W, 1, UI.panelLip, 0.35);
this.add.rectangle(W / 2, 286, W, 1, UI.panelLip, 0.35);
this.add
.text(W / 2, 54, 'the door · press the buttons · nothing here is load-bearing', font(7, UI.paper))
.setOrigin(0.5, 0)
.setAlpha(0.4);
this.add
.text(
TRACK_X0,
336,
'sounds down the left · verdicts down the right · drag MUFFLE to open the door on the bass',
font(7, UI.paper),
)
.setAlpha(0.45);
}
private buildBeatIndicator(): void {
this.beatRing = this.add
.rectangle(BEAT_X, BEAT_Y, BEAT_SIZE + 10, BEAT_SIZE + 10, UI.neonPink, 0)
.setStrokeStyle(1, UI.neonPink, 0.25);
this.beatPad = this.add
.rectangle(BEAT_X, BEAT_Y, BEAT_SIZE, BEAT_SIZE, UI.neonGreen, 0.12)
.setStrokeStyle(1, UI.panelLip);
// Beside the pad, not under it — the backdrop's help line runs the full width
// of this row and a label below the ring lands right on top of it.
this.add.text(BEAT_X - 26, BEAT_Y, 'BEAT', font(7, UI.paper)).setOrigin(1, 0.5).setAlpha(0.5);
}
private buildPatron(): void {
// Stand-in only. The real doll needs a Patron record and lives in patrons/.
const root = this.add.container(PATRON_X, PATRON_Y);
this.patronRoot = root;
root.add(this.add.rectangle(0, 0, 40, 90, UI.grime).setStrokeStyle(1, UI.panelLip));
root.add(this.add.rectangle(0, -58, 26, 26, 0x8a6a52).setStrokeStyle(1, UI.ink));
root.add(this.add.rectangle(0, -14, 40, 3, UI.neonPink, 0.35));
root.add(
this.add.text(0, 56, 'SOME BLOKE', font(7, UI.paper)).setOrigin(0.5, 0).setAlpha(0.5),
);
root.add(
this.add.text(0, 66, 'stamp lands here', font(7, UI.paper)).setOrigin(0.5, 0).setAlpha(0.28),
);
}
private buildStatus(): void {
this.statusText = this.add.text(W - 6, 6, '', font(8, UI.neonGreen)).setOrigin(1, 0);
}
// --- controls -------------------------------------------------------------
private buildSfxColumn(): void {
this.add.text(12, 66, 'SOUNDS', font(7, UI.kebabAmber)).setAlpha(0.8);
SFX_BUTTONS.forEach(([name, label], i) => {
chunkyButton(this, 50, 80 + i * 16, label, {
w: 76,
h: 13,
fontSize: 7,
onClick: () => this.sfx.play(name),
});
});
this.rainButton = chunkyButton(this, 50, 80 + SFX_BUTTONS.length * 16, 'RAIN: OFF', {
w: 76,
h: 13,
fontSize: 7,
fill: UI.grime,
onClick: () => this.toggleRain(),
});
}
private buildActionColumn(): void {
this.add.text(528, 66, 'THE JOB', font(7, UI.kebabAmber)).setAlpha(0.8);
const rows: ReadonlyArray<readonly [string, () => void, number]> = [
['NOT TONIGHT', () => this.doStamp('DENIED', UI.danger), UI.danger],
['IN YOU GO', () => this.doStamp('ADMITTED', UI.ok), UI.ok],
['HAVE A WORD', () => this.openDialogue(false), UI.panelLip],
['ABSOLUTELY GONE', () => this.openDialogue(true), UI.panelLip],
['TEXT FROM DAZZA', () => this.pushDazza(), UI.kebabAmber],
['VIBE UP', () => this.bus.emit('meters:delta', { vibe: 10 }), UI.panelLip],
['VIBE DOWN', () => this.bus.emit('meters:delta', { vibe: -15 }), UI.panelLip],
['AGGRO UP', () => this.bus.emit('meters:delta', { aggro: 15 }), UI.panelLip],
['HYPE UP', () => this.bus.emit('meters:delta', { hype: 0.3 }), UI.panelLip],
['HEAT STRIKE', () => this.bus.emit('heat:strike', { reason: 'let a bloke in wearing crocs' }), UI.danger],
];
rows.forEach(([label, onClick, fill], i) => {
chunkyButton(this, 580, 80 + i * 16, label, {
w: 104,
h: 13,
fontSize: 7,
fill,
textColour: fill === UI.kebabAmber ? UI.ink : UI.paper,
onClick,
});
});
}
private buildAudioStrip(): void {
this.hzText = this.add.text(TRACK_X0, 296, '', font(8, UI.neonGreen));
const track = this.add
.rectangle((TRACK_X0 + TRACK_X1) / 2, TRACK_Y, TRACK_X1 - TRACK_X0, 6, UI.panel)
.setStrokeStyle(1, UI.panelLip);
track.setInteractive({ useHandCursor: true });
track.on('pointerdown', (p: Phaser.Input.Pointer) => this.setCutoffFromX(p.x));
this.handle = this.add
.rectangle(TRACK_X1, TRACK_Y, 8, 16, UI.neonPink)
.setStrokeStyle(1, UI.ink);
this.handle.setInteractive({ useHandCursor: true, draggable: true });
this.handle.on('dragstart', () => {
this.dragging = true;
});
this.handle.on('drag', (_p: Phaser.Input.Pointer, dragX: number) => this.setCutoffFromX(dragX));
this.handle.on('dragend', () => {
this.dragging = false;
});
this.add.text(TRACK_X0, 300, 'MUFFLE', font(7, UI.paper)).setOrigin(0, 1).setAlpha(0.45);
chunkyButton(this, 382, TRACK_Y, 'LET IT GO', {
w: 68,
h: 14,
fontSize: 7,
onClick: () => this.releaseCutoff(),
});
this.locButton = chunkyButton(this, 474, TRACK_Y, 'STEP INSIDE', {
w: 100,
h: 14,
fontSize: 7,
fill: UI.neonPink,
onClick: () => this.toggleLocation(),
});
}
private buildUnlockOverlay(): void {
const root = this.add.container(0, 0).setDepth(2000);
const veil = this.add.rectangle(W / 2, H / 2, W, H, UI.ink, 0.92);
const head = this.add
.text(W / 2, H / 2 - 14, 'CLICK TO START AUDIO', font(16, UI.neonGreen))
.setOrigin(0.5);
const hint = this.add
.text(
W / 2,
H / 2 + 12,
'the browser wants a hand on the door before it lets the bass out',
font(8, UI.paper),
)
.setOrigin(0.5)
.setAlpha(0.55);
root.add([veil, head, hint]);
veil.setInteractive();
veil.on('pointerdown', () => {
if (this.audioStarting) return;
this.audioStarting = true;
head.setText('OPENING UP...');
// A restart mid-unlock destroys this overlay and swaps the engine, but the
// promise still settles. Identity alone is not enough: teardown() destroys
// the engine without replacing it, so `this.engine` still points at the
// closed one. Check the context state too, or we start a closed context and
// poke text objects the next run has already replaced.
const engine = this.engine;
const stale = (): boolean => this.engine !== engine || engine.ctx.state === 'closed';
void engine
.unlock()
.then(() => {
if (stale()) return;
engine.start();
root.destroy(true);
})
.catch(() => {
if (stale()) return;
this.audioStarting = false;
head.setText('AUDIO KNOCKED US BACK');
hint.setText('give it another click');
});
});
}
// --- actions --------------------------------------------------------------
/**
* Marks are parented to the patron so they ride along and die with him, which is
* how the real door will use them. The scene still owns the handles: nothing else
* reaps a decal, so the oldest goes when the stack gets past reading.
*/
private doStamp(text: string, colour: number): void {
while (this.stampMarks.length >= MAX_MARKS) this.stampMarks.shift()?.decal.destroy();
this.stampMarks.push(
stamp(this, {
x: PATRON_X,
y: PATRON_Y - 20,
text,
colour,
depth: 100,
sfx: this.sfx,
parent: this.patronRoot,
// Fresh seed per impression, so a stack of DENIEDs reads as a knackered
// stamp rather than one mark printed five times.
seed: this.rng.stream('juice-demo-stamp').int(0, 0xffff),
onSlam: () => {
if (text === 'DENIED') this.sfx.denyCrowdOoh();
},
}),
);
}
private openDialogue(drunk: boolean): void {
this.dialogue?.destroy();
this.dialogueKill = false;
this.dialogue = new DialogueBox(this, {
speaker: drunk ? 'ABSOLUTELY GONE' : 'BLOKE IN A GOOD SHIRT',
text: drunk
? 'nah mate listen right, im not even that bad, i had like two, ask her, ask her, im basically the designated driver of this whole situation'
: 'evening. me and the boys are on the list, should be under Cam, might be under Cameron, might be under his missus actually.',
drunkenness: drunk ? 0.95 : 0,
rng: this.rng.stream('juice-demo-drunk'),
depth: 950,
sfx: this.sfx,
choices: [
{ id: 'in', label: 'IN YOU GO' },
{ id: 'test', label: 'WALK THE LINE' },
{ id: 'out', label: 'NOT TONIGHT' },
],
onChoice: () => {
this.sfx.clickerClunk();
this.dialogueKill = true;
},
});
}
private pushDazza(): void {
const line = DAZZA_TEXTS[this.dazzaIndex % DAZZA_TEXTS.length];
this.dazzaIndex++;
if (!line) return;
// Synthetic rule: the Phone only cares that the field is present (that is
// what lights the NEW badge), never that the predicate is real.
const rule: DressCodeRule | undefined =
line.ruleId === undefined
? undefined
: { id: line.ruleId, text: line.text, activeFrom: line.fromMin, violates: () => false };
this.bus.emit('dazza:text', { text: line.text, rule });
}
private toggleRain(): void {
this.raining = !this.raining;
if (this.raining) this.sfx.startRain();
else this.sfx.stopRain();
labelOf(this.rainButton)?.setText(this.raining ? 'RAIN: ON' : 'RAIN: OFF');
}
private setCutoffFromX(x: number): void {
const hz = hzFromT((clamp(x, TRACK_X0, TRACK_X1) - TRACK_X0) / (TRACK_X1 - TRACK_X0));
this.overrideHz = hz;
this.engine.setCutoffOverride(hz);
}
private releaseCutoff(): void {
this.overrideHz = null;
this.engine.setCutoffOverride(null);
}
/**
* Goes through the bus rather than calling engine.setLocation directly — the
* point of the button is to prove the `audio:location` path works end to end.
* The override has to go first or the sweep is pinned and you hear nothing.
*/
private toggleLocation(): void {
this.releaseCutoff();
const next = this.engine.location === 'door' ? 'floor' : 'door';
this.bus.emit('audio:location', { location: next });
this.bus.emit('night:phaseChange', { location: next });
labelOf(this.locButton)?.setText(next === 'floor' ? 'BACK ON THE DOOR' : 'STEP INSIDE');
}
// --- loop -----------------------------------------------------------------
override update(_time: number, delta: number): void {
this.clock.update(delta);
this.drainBeats();
this.renderBeat(delta);
this.syncSlider();
this.renderStatus();
this.hud.update(delta);
this.phone.update(delta);
this.clicker.update(delta);
this.dialogue?.update(delta);
if (this.dialogueKill) {
this.dialogueKill = false;
this.dialogue?.destroy();
this.dialogue = null;
}
}
/**
* `audioTimeMs` is the SCHEDULED time and arrives up to a lookahead early.
* Flashing on receipt would put the light ~100ms ahead of the sound, which
* reads as the whole demo being broken. Hold the beat until the audio clock
* catches up to it.
*/
private drainBeats(): void {
const nowMs = this.engine.ctx.currentTime * 1000;
while (this.pendingBeats.length > 0) {
const b = this.pendingBeats[0];
if (b === undefined || b.audioTimeMs > nowMs) break;
this.pendingBeats.shift();
this.lastBeat = b.beatIndex;
if (b.bar) this.lastBar = Math.floor(b.beatIndex / 4);
this.beatFlash = 1;
this.beatFlashBar = b.bar;
}
}
private renderBeat(delta: number): void {
this.beatFlash = Math.max(0, this.beatFlash - delta / FLASH_MS);
const f = this.beatFlash;
const colour = this.beatFlashBar ? UI.neonPink : UI.neonGreen;
this.beatPad.setFillStyle(colour, 0.1 + f * 0.9);
this.beatPad.setScale(1 + f * (this.beatFlashBar ? 0.42 : 0.16));
this.beatRing.setStrokeStyle(1, colour, this.beatFlashBar ? 0.2 + f * 0.6 : 0.18);
this.beatRing.setScale(this.beatFlashBar ? 1 + f * 0.25 : 1);
}
/** In AUTO the handle rides the engine's live sweep; while dragging it leads. */
private syncSlider(): void {
if (this.dragging) return;
const t = tFromHz(this.overrideHz ?? this.engine.cutoffHz);
this.handle.x = TRACK_X0 + t * (TRACK_X1 - TRACK_X0);
}
private renderStatus(): void {
const hz = Math.round(this.engine.cutoffHz);
this.hzText.setText(`${hz} Hz ${this.overrideHz === null ? '(AUTO)' : '(HELD)'}`);
const audio = this.engine.unlocked ? (this.engine.running ? 'LIVE' : 'IDLE') : 'LOCKED';
this.statusText.setText(
[
this.clock.label,
`AUDIO ${audio} · ${this.engine.bpm} BPM`,
`BEAT ${this.lastBeat} · BAR ${this.lastBar}`,
`${this.engine.location.toUpperCase()} · ${hz} Hz`,
].join('\n'),
);
}
private teardown(): void {
this.input.keyboard?.off('keydown-SPACE');
for (const m of this.stampMarks) m.decal.destroy();
this.stampMarks.length = 0;
this.dialogue?.destroy();
this.dialogue = null;
this.phone.destroy();
this.clicker.destroy();
this.hud.destroy();
// Sfx first: engine.destroy() closes the context its nodes hang off.
this.sfx.destroy();
this.engine.destroy();
this.meters.destroy();
this.bus.removeAll();
}
}