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>
565 lines
20 KiB
TypeScript
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();
|
|
}
|
|
}
|