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 = [ ['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 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(); } }