From 2eb3c94ccdcadef71f379437a31cacd3ee46e7d5 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 19 Jul 2026 18:52:43 +1000 Subject: [PATCH] LANE-JUICE: audio engine, SFX set, five UI widgets, JuiceDemoScene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/audio/Sfx.ts | 455 ++++++++++++++++++++++++++++++ src/audio/TechnoEngine.ts | 460 +++++++++++++++++++++++++++++++ src/audio/filterCurve.ts | 9 +- src/main.ts | 32 ++- src/ui/Clicker.ts | 178 ++++++++++++ src/ui/DialogueBox.ts | 288 +++++++++++++++++++ src/ui/JuiceDemoScene.ts | 564 ++++++++++++++++++++++++++++++++++++++ src/ui/MeterHud.ts | 250 +++++++++++++++++ src/ui/Phone.ts | 396 ++++++++++++++++++++++++++ src/ui/Stamp.ts | 268 ++++++++++++++++++ src/ui/style.ts | 22 +- src/ui/typo.ts | 25 +- tests/filterCurve.test.ts | 200 ++++++++++++++ tests/scheduling.test.ts | 212 ++++++++++++++ tests/typo.test.ts | 211 ++++++++++++++ 15 files changed, 3557 insertions(+), 13 deletions(-) create mode 100644 src/audio/Sfx.ts create mode 100644 src/audio/TechnoEngine.ts create mode 100644 src/ui/Clicker.ts create mode 100644 src/ui/DialogueBox.ts create mode 100644 src/ui/JuiceDemoScene.ts create mode 100644 src/ui/MeterHud.ts create mode 100644 src/ui/Phone.ts create mode 100644 src/ui/Stamp.ts create mode 100644 tests/filterCurve.test.ts create mode 100644 tests/scheduling.test.ts create mode 100644 tests/typo.test.ts diff --git a/src/audio/Sfx.ts b/src/audio/Sfx.ts new file mode 100644 index 0000000..3908c7d --- /dev/null +++ b/src/audio/Sfx.ts @@ -0,0 +1,455 @@ +// Procedural one-shots. Every sound is synthesized from noise + oscillators — +// no samples ship with the game, so the whole SFX set costs zero bytes of +// download and every parameter is tweakable from here. +// +// These connect to TechnoEngine's sfxBus, which bypasses the location lowpass. +// That is deliberate: the music muffles when you are at the door, but the stamp +// under your own hand does not. The contrast is the joke. + +export type SfxName = + | 'stampSlam' + | 'stampInk' + | 'clickerClunk' + | 'doorBang' + | 'radioStatic' + | 'radioChirp' + | 'phoneBuzz' + | 'ropeUnhook' + | 'denyCrowdOoh' + | 'typeTick'; + +/** Seconds of white noise generated once and shared by every noise voice. */ +const NOISE_SECONDS = 2; + +/** + * Read heads into the shared noise buffer. Successive hits start at different + * offsets so a run of stamps does not sound like the same recording twice — + * a deterministic stand-in for the Math.random the house rules forbid. + */ +const NOISE_OFFSETS = [0, 0.31, 0.77, 0.13, 0.94, 0.52, 1.19, 0.68] as const; + +/** Chain jingle: link strike times (s) and gains. Uneven on purpose. */ +const ROPE_PINGS = [ + { at: 0, gain: 0.3, hz: 5200 }, + { at: 0.026, gain: 0.19, hz: 6400 }, + { at: 0.058, gain: 0.27, hz: 4600 }, + { at: 0.081, gain: 0.14, hz: 7100 }, + { at: 0.117, gain: 0.21, hz: 5800 }, + { at: 0.149, gain: 0.11, hz: 6900 }, +] as const; + +/** Gain steps across the static burst — flat noise reads as tape hiss, not radio. */ +const STATIC_CRACKLE = [0.34, 0.12, 0.41, 0.2, 0.5, 0.16, 0.29, 0.38, 0.09, 0.22] as const; + +/** WebAudio refuses an exponential ramp to zero; this is close enough to silence. */ +const SILENT = 0.0001; + +export class Sfx { + private readonly ctx: AudioContext; + private readonly dest: AudioNode; + private readonly noiseBuffer: AudioBuffer; + + /** Sources still playing — destroy() has to be able to cut them mid-flight. */ + private readonly live = new Set(); + + private offsetCursor = 0; + + private rain: { src: AudioBufferSourceNode; lfo: OscillatorNode; gain: GainNode } | null = null; + + constructor(ctx: AudioContext, destination: AudioNode) { + this.ctx = ctx; + this.dest = destination; + this.noiseBuffer = makeNoise(ctx); + } + + play(name: SfxName): void { + switch (name) { + case 'stampSlam': + return this.stampSlam(); + case 'stampInk': + return this.stampInk(); + case 'clickerClunk': + return this.clickerClunk(); + case 'doorBang': + return this.doorBang(); + case 'radioStatic': + return this.radioStatic(); + case 'radioChirp': + return this.radioChirp(); + case 'phoneBuzz': + return this.phoneBuzz(); + case 'ropeUnhook': + return this.ropeUnhook(); + case 'denyCrowdOoh': + return this.denyCrowdOoh(); + case 'typeTick': + return this.typeTick(); + } + } + + /** + * The signature sound. Two layers that must land on the same sample: a body + * thump you feel and a paper crack you hear. Drop the crack and it turns into + * a kick drum; drop the thump and the stamp weighs nothing. + */ + stampSlam(): void { + const t = this.ctx.currentTime; + + const thump = this.ctx.createOscillator(); + thump.type = 'sine'; + thump.frequency.setValueAtTime(170, t); + thump.frequency.exponentialRampToValueAtTime(52, t + 0.09); + this.voice(thump, [this.env(t, 0.001, 0.01, 0.26, 0.95)], t, 0.3); + + // Bandpassed rather than open noise: a wide burst sounds like a cymbal, a + // narrow one at 2.6k sounds like a page taking a hit. + const snap = this.noiseSource(); + this.voice( + snap, + [this.band(2600, 1.1), this.env(t, 0.0006, 0.004, 0.07, 0.55)], + t, + 0.09, + ); + + // A hair of top end, delayed a touch, is the wooden handle bottoming out. + const knock = this.noiseSource(); + this.voice( + knock, + [this.band(620, 2.4), this.env(t + 0.008, 0.001, 0.004, 0.05, 0.3)], + t + 0.008, + 0.06, + ); + } + + /** Damp, not percussive — the soft attack is what stops it reading as a click. */ + stampInk(): void { + const t = this.ctx.currentTime; + + const lp = this.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.Q.value = 6; + lp.frequency.setValueAtTime(2000, t); + lp.frequency.exponentialRampToValueAtTime(210, t + 0.13); + + const src = this.noiseSource(); + src.playbackRate.value = 0.7; + this.voice(src, [lp, this.env(t, 0.018, 0.02, 0.16, 0.32)], t, 0.22); + } + + /** Two clicks; the ear hears the pair as one mechanism, not two events. */ + clickerClunk(): void { + const t = this.ctx.currentTime; + this.click(t, 2100, 0.5); + this.click(t + 0.035, 1720, 0.28); + } + + /** + * The toilet rhythm game cues off this through a closed door and a techno + * mix, so it is the loudest thing in the set by a clear margin. + */ + doorBang(): void { + const t = this.ctx.currentTime; + + const thud = this.ctx.createOscillator(); + thud.type = 'sine'; + thud.frequency.setValueAtTime(120, t); + thud.frequency.exponentialRampToValueAtTime(36, t + 0.14); + this.voice(thud, [this.env(t, 0.001, 0.02, 0.4, 1.1)], t, 0.46); + + const slap = this.noiseSource(); + this.voice(slap, [this.band(880, 0.8), this.env(t, 0.001, 0.01, 0.13, 0.7)], t, 0.16); + + // High-Q noise band == a struck panel resonating. Cheaper than modelling it. + const ring = this.noiseSource(); + this.voice(ring, [this.band(370, 18), this.env(t, 0.002, 0.03, 0.42, 0.5)], t, 0.5); + } + + radioStatic(): void { + const t = this.ctx.currentTime; + const dur = 0.35; + + const g = this.ctx.createGain(); + g.gain.setValueAtTime(SILENT, t); + // Stepped, not ramped: hard jumps between levels are what crackle sounds like. + for (let i = 0; i < STATIC_CRACKLE.length; i++) { + const level = STATIC_CRACKLE[i] ?? 0.2; + g.gain.setValueAtTime(level * 0.6, t + (i / STATIC_CRACKLE.length) * dur); + } + g.gain.setValueAtTime(0.18, t + dur * 0.94); + g.gain.exponentialRampToValueAtTime(SILENT, t + dur); + + const src = this.noiseSource(); + this.voice(src, [this.band(1800, 0.9), g], t, dur); + } + + /** The squelch tail you get when someone lets go of the PTT. */ + radioChirp(): void { + const t = this.ctx.currentTime; + + const tone = this.ctx.createOscillator(); + tone.type = 'sawtooth'; + tone.frequency.setValueAtTime(2400, t); + tone.frequency.exponentialRampToValueAtTime(680, t + 0.075); + this.voice(tone, [this.band(1600, 1.6), this.env(t, 0.002, 0.02, 0.07, 0.22)], t, 0.11); + + const tick = this.noiseSource(); + this.voice( + tick, + [this.band(3200, 1.2), this.env(t + 0.078, 0.001, 0.004, 0.03, 0.2)], + t + 0.078, + 0.04, + ); + } + + /** + * Phone face-down on the bar. The 28Hz amplitude modulation is the whole + * effect — an unmodulated 55Hz tone is a bass note, the same tone chopped at + * 28Hz is a plastic case rattling on laminate. + */ + phoneBuzz(): void { + const t = this.ctx.currentTime; + this.buzzBurst(t, 0.15); + this.buzzBurst(t + 0.24, 0.15); + } + + ropeUnhook(): void { + const t = this.ctx.currentTime; + for (const ping of ROPE_PINGS) { + const at = t + ping.at; + const hp = this.ctx.createBiquadFilter(); + hp.type = 'highpass'; + hp.frequency.value = ping.hz; + const src = this.noiseSource(); + this.voice(src, [hp, this.env(at, 0.001, 0.003, 0.05, ping.gain)], at, 0.06); + } + } + + /** + * Six people at the rope who just watched someone get knocked back. Two + * bandpasses sweeping upward together fake the vowel moving "oo" -> "ooh"; + * the slow swell keeps it a murmur instead of a boo. + */ + denyCrowdOoh(): void { + const t = this.ctx.currentTime; + const dur = 0.62; + + const swell = this.ctx.createGain(); + swell.gain.setValueAtTime(SILENT, t); + swell.gain.linearRampToValueAtTime(0.3, t + 0.24); + swell.gain.setValueAtTime(0.3, t + 0.34); + swell.gain.exponentialRampToValueAtTime(SILENT, t + dur); + + const formant = (base: number, top: number, level: number): AudioNode[] => { + const f = this.ctx.createBiquadFilter(); + f.type = 'bandpass'; + f.Q.value = 5; + f.frequency.setValueAtTime(base, t); + f.frequency.linearRampToValueAtTime(top, t + 0.5); + const trim = this.ctx.createGain(); + trim.gain.value = level; + return [f, trim]; + }; + + const low = this.noiseSource(); + low.playbackRate.value = 0.5; + // The lower formant owns the shared swell node, so it is disconnected with + // the rest of that chain on `ended` — both branches stop at the same time. + this.voice(low, [...formant(360, 560, 1), swell], t, dur); + + const high = this.noiseSource(); + high.playbackRate.value = 0.5; + this.voiceInto(high, formant(860, 1180, 0.45), swell, t, dur); + } + + /** + * Fires once per typed character, so restraint is the entire spec. Anything + * louder or longer than this becomes unbearable by the third line of dialogue. + */ + typeTick(): void { + const t = this.ctx.currentTime; + const src = this.noiseSource(); + this.voice(src, [this.band(3400, 2.2), this.env(t, 0.0005, 0.002, 0.012, 0.045)], t, 0.016); + } + + /** Idempotent — the door scene may call this on every re-entry. */ + startRain(): void { + if (this.rain) return; + const t = this.ctx.currentTime; + + const src = this.ctx.createBufferSource(); + src.buffer = this.noiseBuffer; + src.loop = true; + + const lp = this.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 3200; + + const gain = this.ctx.createGain(); + gain.gain.setValueAtTime(SILENT, t); + gain.gain.linearRampToValueAtTime(0.1, t + 1.2); + + // A slow tremolo stops the bed reading as a static hiss the ear tunes out. + const lfo = this.ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.value = 0.13; + const lfoDepth = this.ctx.createGain(); + lfoDepth.gain.value = 0.028; + lfo.connect(lfoDepth).connect(gain.gain); + + src.connect(lp).connect(gain).connect(this.dest); + src.start(t); + lfo.start(t); + this.rain = { src, lfo, gain }; + } + + /** Fades rather than cuts — a hard stop on a noise bed is audible as a click. */ + stopRain(): void { + const rain = this.rain; + if (!rain) return; + this.rain = null; + + const t = this.ctx.currentTime; + const fade = 0.9; + rain.gain.gain.cancelScheduledValues(t); + rain.gain.gain.setValueAtTime(Math.max(rain.gain.gain.value, SILENT), t); + rain.gain.gain.exponentialRampToValueAtTime(SILENT, t + fade); + + rain.src.onended = () => { + rain.src.disconnect(); + rain.gain.disconnect(); + }; + rain.src.stop(t + fade); + rain.lfo.stop(t + fade); + rain.lfo.onended = () => rain.lfo.disconnect(); + } + + destroy(): void { + this.stopRain(); + for (const src of this.live) { + try { + src.stop(); + } catch { + // Already stopped or never started — nothing to unwind. + } + src.disconnect(); + } + this.live.clear(); + } + + // --- voices ------------------------------------------------------------- + + /** One filtered click. Shared by the clicker's two halves. */ + private click(at: number, hz: number, level: number): void { + const src = this.noiseSource(); + this.voice(src, [this.band(hz, 12), this.env(at, 0.0008, 0.002, 0.028, level)], at, 0.04); + } + + private buzzBurst(at: number, dur: number): void { + const carrier = this.ctx.createOscillator(); + carrier.type = 'triangle'; + carrier.frequency.value = 55; + + // Modulator swings the ring gain around 0.5, so the carrier is chopped to + // near-silence and back 28 times a second. + const ring = this.ctx.createGain(); + ring.gain.value = 0.5; + const mod = this.ctx.createOscillator(); + mod.type = 'square'; + mod.frequency.value = 28; + const modDepth = this.ctx.createGain(); + modDepth.gain.value = 0.5; + mod.connect(modDepth).connect(ring.gain); + mod.start(at); + mod.stop(at + dur); + mod.onended = () => { + mod.disconnect(); + modDepth.disconnect(); + }; + + this.voice(carrier, [ring, this.env(at, 0.006, dur - 0.03, 0.02, 0.4)], at, dur); + } + + // --- plumbing ----------------------------------------------------------- + + private noiseSource(): AudioBufferSourceNode { + const src = this.ctx.createBufferSource(); + src.buffer = this.noiseBuffer; + return src; + } + + private band(hz: number, q: number): BiquadFilterNode { + const f = this.ctx.createBiquadFilter(); + f.type = 'bandpass'; + f.frequency.value = hz; + f.Q.value = q; + return f; + } + + /** Attack/hold/decay gain envelope, already scheduled against audio time. */ + private env(at: number, attack: number, hold: number, decay: number, peak: number): GainNode { + const g = this.ctx.createGain(); + g.gain.setValueAtTime(SILENT, at); + g.gain.linearRampToValueAtTime(peak, at + attack); + g.gain.setValueAtTime(peak, at + attack + hold); + g.gain.exponentialRampToValueAtTime(SILENT, at + attack + hold + decay); + return g; + } + + private voice( + src: AudioScheduledSourceNode, + chain: readonly AudioNode[], + at: number, + dur: number, + ): void { + this.voiceInto(src, chain, this.dest, at, dur); + } + + /** + * Wires src -> chain -> out, plays it, and disconnects the whole chain on + * `ended`. Without this a long night leaks a few hundred dead nodes. + */ + private voiceInto( + src: AudioScheduledSourceNode, + chain: readonly AudioNode[], + out: AudioNode, + at: number, + dur: number, + ): void { + let node: AudioNode = src; + for (const n of chain) node = node.connect(n); + node.connect(out); + + this.live.add(src); + src.onended = () => { + this.live.delete(src); + src.disconnect(); + for (const n of chain) n.disconnect(); + }; + + if (src instanceof AudioBufferSourceNode) src.start(at, this.nextOffset(), dur); + else src.start(at); + src.stop(at + dur); + } + + private nextOffset(): number { + const offset = NOISE_OFFSETS[this.offsetCursor % NOISE_OFFSETS.length] ?? 0; + this.offsetCursor++; + return offset; + } +} + +/** + * White noise from a fixed-seed LCG. Deterministic by house rule, and one + * buffer for the whole night — regenerating per hit would allocate megabytes. + */ +function makeNoise(ctx: AudioContext): AudioBuffer { + const length = Math.floor(ctx.sampleRate * NOISE_SECONDS); + const buffer = ctx.createBuffer(1, length, ctx.sampleRate); + const data = buffer.getChannelData(0); + let seed = 0x2f6e2b1; + for (let i = 0; i < length; i++) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + data[i] = (seed / 0xffffffff) * 2 - 1; + } + return buffer; +} diff --git a/src/audio/TechnoEngine.ts b/src/audio/TechnoEngine.ts new file mode 100644 index 0000000..5cb0644 --- /dev/null +++ b/src/audio/TechnoEngine.ts @@ -0,0 +1,460 @@ +import type { EventBus } from '../core/EventBus'; +import { SeededRNG } from '../core/SeededRNG'; +import { + type AudioLocation, + cutoffFor, + gainFor, + sweepMsFor, +} from './filterCurve'; +import { + type BeatGrid, + barOf, + beatIntervalMs, + beatTimeMs, + dueBeats, + isDownbeat, + patternStep, + subdivisions, +} from './scheduling'; +import '../ui/juiceEvents'; + +const DEFAULT_BPM = 128; +const DEFAULT_LOOKAHEAD_MS = 100; +const TIMER_MS = 25; + +/** + * Beat 0 sits this far past the resume instant. Scheduling into the past is not + * an error — the node just fires immediately — but then the beat *sounds* later + * than the `audioTimeMs` we promised for it. The toilet rhythm game scores + * against that number, so the first beat has to be as honest as the rest. + */ +const START_LEAD_MS = 60; + +const MASTER_GAIN = 0.8; + +// Resonance: a hair of peak at the cutoff is what reads as "bass through a wall" +// rather than "someone turned the treble down". Flat once you're on the floor. +const DOOR_Q = 6; +const FLOOR_Q = 0.7; + +const KICK_PEAK = 0.9; +const HAT_PEAK = 0.25; +const BASS_PEAK = 0.11; +const PAD_PEAK = 0.05; + +const SILENT = 0.0001; // exponential ramps cannot reach 0 + +const BASS_ROOT_HZ = 55; // A1 +const BASS_STEPS = 32; // 2 bars of 16ths +const PAD_BARS = 8; + +/** + * Semitones from A1, one entry per 16th. Nothing lands on a downbeat — the kick + * owns that slot, and leaving it empty is what makes the bassline roll instead + * of thud. + */ +const BASS_PATTERN: readonly (number | null)[] = [ + null, null, 0, 0, + null, 0, null, 0, + null, null, 3, 3, + null, 0, null, 12, + null, null, 0, 0, + null, 0, null, 0, + null, null, 5, 3, + null, 0, 2, null, +]; + +/** A minor, voiced wide and low. Detuning the pair is the whole character. */ +const PAD_VOICES: ReadonlyArray<{ hz: number; detune: number; type: OscillatorType }> = [ + { hz: 110, detune: -7, type: 'sawtooth' }, + { hz: 110, detune: 7, type: 'sawtooth' }, + { hz: 164.81, detune: 0, type: 'triangle' }, +]; + +export interface TechnoEngineOptions { + bpm?: number; + lookaheadMs?: number; +} + +/** + * The looping techno track and the beat authority in one. Replaces + * core/StubBeatClock — same `beat:tick` contract, but the beats now come off the + * AudioContext clock instead of a frame counter, so they are actually true. + * + * Everything is synthesized; there are no samples to load and nothing to + * preload. All scheduling happens on a 25ms timer looking `lookaheadMs` into the + * future. That timer is throttled on a blurred tab exactly as rAF is, so the + * look-ahead alone does not save us; what does is `resync()`, which drops the + * span that elapsed while we were asleep instead of dumping it into the graph. + */ +export class TechnoEngine { + readonly ctx: AudioContext; + readonly master: GainNode; + readonly musicBus: GainNode; + readonly sfxBus: GainNode; + readonly bpm: number; + + private readonly filter: BiquadFilterNode; + private readonly noiseBuffer: AudioBuffer; + private readonly lookaheadMs: number; + private readonly grid: BeatGrid; + private readonly unsubs: Array<() => void> = []; + /** + * Long-lived handles so stop() can silence what is already on the schedule. + * Keyed by source, valued by the rest of that voice's chain, because stop() + * pre-empts `onended` and would otherwise strand those nodes on the bus. + */ + private readonly voices = new Map(); + + private timer: ReturnType | null = null; + private nextBeat = 0; + private loc: AudioLocation = 'door'; + private override: number | null = null; + private unlockedFlag = false; + private unlocking: Promise | null = null; + + constructor( + private readonly bus: EventBus, + opts: TechnoEngineOptions = {}, + ) { + this.bpm = opts.bpm ?? DEFAULT_BPM; + this.lookaheadMs = opts.lookaheadMs ?? DEFAULT_LOOKAHEAD_MS; + this.ctx = new AudioContext(); + this.grid = { bpm: this.bpm, originMs: 0 }; + + this.master = this.ctx.createGain(); + this.master.gain.value = MASTER_GAIN; + this.master.connect(this.ctx.destination); + + this.filter = this.ctx.createBiquadFilter(); + this.filter.type = 'lowpass'; + this.filter.frequency.value = cutoffFor(this.loc); + this.filter.Q.value = DOOR_Q; + this.filter.connect(this.master); + + this.musicBus = this.ctx.createGain(); + this.musicBus.gain.value = gainFor(this.loc); + this.musicBus.connect(this.filter); + + // Deliberately NOT through the filter: a stamp must sound like a stamp even + // when the music is buried behind a door. + this.sfxBus = this.ctx.createGain(); + this.sfxBus.connect(this.master); + + this.noiseBuffer = this.buildNoise(); + + this.unsubs.push( + this.bus.on('audio:location', ({ location }) => this.setLocation(location)), + ); + } + + get running(): boolean { + return this.timer !== null; + } + + get unlocked(): boolean { + return this.unlockedFlag; + } + + get cutoffHz(): number { + return this.filter.frequency.value; + } + + get location(): AudioLocation { + return this.loc; + } + + /** + * Resume after a real user gesture. Idempotent, and safe to call concurrently + * — overlapping callers await the same resume rather than racing the origin. + */ + async unlock(): Promise { + if (this.unlockedFlag) return; + if (this.unlocking) return this.unlocking; + + this.unlocking = (async () => { + if (this.ctx.state === 'suspended') await this.ctx.resume(); + const atMs = this.ctx.currentTime * 1000; + // Re-origin the grid: beat 0 is now, not whenever this object was built. + this.grid.originMs = atMs + START_LEAD_MS; + this.nextBeat = 0; + this.unlockedFlag = true; + this.bus.emit('audio:unlocked', { atMs }); + })(); + + try { + await this.unlocking; + } finally { + this.unlocking = null; + } + } + + /** + * Start the scheduler. Starting before `unlock()` is legal but silent: the + * timer spins and emits nothing until the gesture lands. Callers therefore + * never have to sequence start/unlock. + * + * Restarting after a stop needs no re-anchoring here: nothing is scheduled + * outside `tick()`, and its first wake resyncs past the gap. + */ + start(): void { + if (this.timer !== null) return; + this.timer = setInterval(() => this.tick(), TIMER_MS); + } + + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + for (const [src, chain] of [...this.voices]) { + src.onended = null; + try { + src.stop(); + } catch { + /* already finished — nothing to silence */ + } + this.release(src, chain); + } + this.voices.clear(); + } + + /** Ramp to the location's cutoff/gain. A pinned override wins over both. */ + setLocation(location: AudioLocation, immediate = false): void { + this.loc = location; + if (this.override !== null) return; + + const durS = immediate ? 0 : sweepMsFor(location) / 1000; + this.ramp(this.filter.frequency, cutoffFor(location), durS, true); + this.ramp(this.musicBus.gain, gainFor(location), durS, false); + this.ramp(this.filter.Q, location === 'floor' ? FLOOR_Q : DOOR_Q, durS, false); + } + + /** Non-null pins the cutoff and suppresses location sweeps; null hands it back. */ + setCutoffOverride(hz: number | null): void { + this.override = hz; + if (hz === null) { + this.setLocation(this.loc); + return; + } + this.ramp(this.filter.frequency, this.clampHz(hz), 0, true); + } + + destroy(): void { + this.stop(); + for (const off of this.unsubs) off(); + this.unsubs.length = 0; + void this.ctx.close().catch(() => undefined); + } + + // --- scheduling ----------------------------------------------------------- + + private tick(): void { + if (!this.unlockedFlag) return; + + const nowMs = this.ctx.currentTime * 1000; + this.resync(nowMs); + const due = dueBeats(this.grid, this.nextBeat, nowMs, this.lookaheadMs); + if (due.length === 0) return; + + for (const { beatIndex, timeMs } of due) { + this.scheduleBeat(beatIndex, timeMs); + // The SCHEDULED time, not the current one. Consumers time their input + // against this, so a convenient lie here is a broken rhythm game. + this.bus.emit('beat:tick', { beatIndex, audioTimeMs: timeMs }); + if (isDownbeat(beatIndex)) { + this.bus.emit('beat:bar', { barIndex: barOf(beatIndex), beatIndex, audioTimeMs: timeMs }); + } + } + + const last = due[due.length - 1]; + if (last) this.nextBeat = last.beatIndex + 1; + } + + /** + * Skip a stale span rather than catch up on it. A throttled timer (hidden tab) + * or a long stop/start gap leaves the cursor minutes behind the audio clock; + * playing those beats would start every voice on the same sample — a clipped + * blast, not music — and `beat:tick` would hand the rhythm game timestamps it + * could only score as misses. The grace is one beat: ordinary wakeup jitter + * must not trigger a jump. + * + * Landing on a downbeat rather than the nearest beat keeps bar phase, so the + * resync reads as a cut instead of the track lurching sideways. + */ + private resync(nowMs: number): void { + const interval = beatIntervalMs(this.bpm); + if (beatTimeMs(this.grid, this.nextBeat) >= nowMs - interval) return; + const firstFuture = Math.ceil((nowMs - this.grid.originMs) / interval); + this.nextBeat = Math.ceil(firstFuture / 4) * 4; + } + + private scheduleBeat(beatIndex: number, timeMs: number): void { + const sixteenths = subdivisions(this.grid, beatIndex, 4); + + this.kick(timeMs / 1000); + + const offbeat = sixteenths[2]; // the 8th between beats + if (offbeat !== undefined) this.hat(offbeat / 1000); + + for (let s = 0; s < sixteenths.length; s++) { + const at = sixteenths[s]; + const semi = BASS_PATTERN[patternStep(beatIndex, s, BASS_STEPS)]; + if (at === undefined || semi === undefined || semi === null) continue; + this.bass(at / 1000, BASS_ROOT_HZ * Math.pow(2, semi / 12)); + } + + if (isDownbeat(beatIndex) && barOf(beatIndex) % PAD_BARS === 0) this.pad(timeMs / 1000); + } + + // --- voices --------------------------------------------------------------- + + private kick(at: number): void { + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = 'sine'; + + // The pitch drop is the beater; the tail is the cone. This is the one voice + // that has to survive a 250Hz lowpass, so it gets a long body. + osc.frequency.setValueAtTime(150, at); + osc.frequency.exponentialRampToValueAtTime(50, at + 0.06); + + gain.gain.setValueAtTime(SILENT, at); + gain.gain.exponentialRampToValueAtTime(KICK_PEAK, at + 0.008); + gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.34); + + osc.connect(gain).connect(this.musicBus); + osc.start(at); + osc.stop(at + 0.36); + this.hold(osc, [gain]); + } + + private hat(at: number): void { + const src = this.ctx.createBufferSource(); + src.buffer = this.noiseBuffer; + // Cheap variety: start the read head somewhere different each hit so the + // one shared buffer doesn't ring identically 400 times a minute. + const offset = (at * 7.3) % (this.noiseBuffer.duration - 0.1); + + const hp = this.ctx.createBiquadFilter(); + hp.type = 'highpass'; + hp.frequency.value = 7000; + + const gain = this.ctx.createGain(); + gain.gain.setValueAtTime(HAT_PEAK, at); + gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.04); + + src.connect(hp).connect(gain).connect(this.musicBus); + src.start(at, offset, 0.06); + this.hold(src, [hp, gain]); + } + + private bass(at: number, hz: number): void { + const lp = this.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.Q.value = 8; + // Per-note filter sweep — the "wow" that stops 16ths sounding like a fax. + lp.frequency.setValueAtTime(180, at); + lp.frequency.exponentialRampToValueAtTime(900, at + 0.03); + lp.frequency.exponentialRampToValueAtTime(200, at + 0.11); + + const gain = this.ctx.createGain(); + gain.gain.setValueAtTime(SILENT, at); + gain.gain.exponentialRampToValueAtTime(BASS_PEAK, at + 0.006); + gain.gain.exponentialRampToValueAtTime(SILENT, at + 0.12); + + lp.connect(gain).connect(this.musicBus); + + for (const detune of [-8, 8]) { + const osc = this.ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.value = hz; + osc.detune.value = detune; + osc.connect(lp); + osc.start(at); + osc.stop(at + 0.13); + this.hold(osc, detune > 0 ? [lp, gain] : []); + } + } + + private pad(at: number): void { + const cycle = (PAD_BARS * 4 * beatIntervalMs(this.bpm)) / 1000; + + const lp = this.ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 600; + + const gain = this.ctx.createGain(); + gain.gain.setValueAtTime(0, at); + gain.gain.linearRampToValueAtTime(PAD_PEAK, at + cycle * 0.4); + gain.gain.linearRampToValueAtTime(0, at + cycle); + + lp.connect(gain).connect(this.musicBus); + + PAD_VOICES.forEach((v, i) => { + const osc = this.ctx.createOscillator(); + osc.type = v.type; + osc.frequency.value = v.hz; + osc.detune.value = v.detune; + osc.connect(lp); + osc.start(at); + osc.stop(at + cycle); + this.hold(osc, i === PAD_VOICES.length - 1 ? [lp, gain] : []); + }); + } + + // --- plumbing ------------------------------------------------------------- + + /** + * One second of deterministic white noise, built once and re-read per hat. + * Allocating a buffer per hit would mean ~8 buffers a second for six hours. + */ + private buildNoise(): AudioBuffer { + const len = Math.floor(this.ctx.sampleRate); + const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate); + const data = buf.getChannelData(0); + const rng = new SeededRNG(0x1a7).stream('hat-noise'); + for (let i = 0; i < len; i++) data[i] = rng.next() * 2 - 1; + return buf; + } + + /** Keep the node reachable for stop(); let it free itself if it runs out. */ + private hold(src: AudioScheduledSourceNode, chain: AudioNode[]): void { + this.voices.set(src, chain); + src.onended = () => this.release(src, chain); + } + + private release(src: AudioScheduledSourceNode, chain: AudioNode[]): void { + this.voices.delete(src); + src.disconnect(); + for (const n of chain) n.disconnect(); + } + + /** + * Re-anchor at the live value before ramping so a sweep interrupted mid-flight + * (door -> floor -> door in two seconds, which players absolutely do) continues + * from where it actually is instead of snapping. + * + * Exponential ramps trace exactly `cutoffAt()`'s curve — v0*(v1/v0)^t — so we + * get cutoffCurve()'s shape without setValueCurveAtTime's rule that a new curve + * may not overlap a running one. That overlap throws, and re-entrant location + * changes are the normal case here, not the edge case. + */ + private ramp(param: AudioParam, to: number, durS: number, exponential: boolean): void { + const now = this.ctx.currentTime; + const from = param.value; + param.cancelScheduledValues(now); + param.setValueAtTime(from, now); + if (durS <= 0) { + param.setValueAtTime(to, now); + } else if (exponential && from > 0 && to > 0) { + param.exponentialRampToValueAtTime(to, now + durS); + } else { + param.linearRampToValueAtTime(to, now + durS); + } + } + + private clampHz(hz: number): number { + return Math.max(20, Math.min(hz, this.ctx.sampleRate / 2)); + } +} diff --git a/src/audio/filterCurve.ts b/src/audio/filterCurve.ts index 5ffa549..3fd8feb 100644 --- a/src/audio/filterCurve.ts +++ b/src/audio/filterCurve.ts @@ -55,7 +55,12 @@ export function gainAt(t: number, from: number, to: number): number { * curve's shape in tests and keeps door/floor behaviour identical everywhere. */ export function cutoffCurve(fromHz: number, toHz: number, points = 32): Float32Array { - const out = new Float32Array(points); - for (let i = 0; i < points; i++) out[i] = cutoffAt(i / (points - 1), fromHz, toHz); + // A degenerate count must still yield a usable curve: a NaN reaching + // setValueCurveAtTime throws, and a NaN assigned to AudioParam.value kills the + // filter silently, which would read as "the music stopped" with nothing logged. + const n = Number.isFinite(points) ? Math.max(1, Math.floor(points)) : 1; + const span = Math.max(1, n - 1); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = cutoffAt(i / span, fromHz, toHz); return out; } diff --git a/src/main.ts b/src/main.ts index 38c90af..668df57 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import { BootScene } from './scenes/shared/BootScene'; import { ParadeScene } from './scenes/shared/ParadeScene'; +import { JuiceDemoScene } from './ui/JuiceDemoScene'; const game = new Phaser.Game({ type: Phaser.AUTO, @@ -13,8 +14,37 @@ const game = new Phaser.Game({ autoCenter: Phaser.Scale.CENTER_BOTH, zoom: Phaser.Scale.MAX_ZOOM, }, - scene: [BootScene, ParadeScene], + scene: [BootScene, ParadeScene, JuiceDemoScene], }); // debug handle (harmless in prod; used by dev tooling) (window as unknown as { game: Phaser.Game }).game = game; + +// TODO(integration): LANE-JUICE demo route — J for the juice demo, P back to the +// parade, or load with #juice. There is no menu yet; Phase 2's shell replaces this +// whole block. Touching main.ts is outside LANE-JUICE's owned dirs (BUILD_PLAN §2) +// and is flagged for sign-off in LANEHANDOVER.md — without it the demo scene is +// unreachable dead code. +const showScene = (key: 'Parade' | 'JuiceDemo'): void => { + const other = key === 'Parade' ? 'JuiceDemo' : 'Parade'; + game.scene.stop(other); + if (!game.scene.isActive(key)) game.scene.start(key); +}; + +// BootScene starts Parade itself, and does it after READY fires — so routing on +// READY stops a scene that is not up yet and we end up with both running on top +// of each other. Wait for Parade to actually exist, then swap. +if (window.location.hash === '#juice') { + const route = (): void => { + if (!game.scene.isActive('Parade')) return; + game.events.off(Phaser.Core.Events.POST_STEP, route); + showScene('JuiceDemo'); + }; + game.events.on(Phaser.Core.Events.POST_STEP, route); +} + +window.addEventListener('keydown', (e) => { + const k = e.key.toLowerCase(); + if (k === 'j') showScene('JuiceDemo'); + else if (k === 'p') showScene('Parade'); +}); diff --git a/src/ui/Clicker.ts b/src/ui/Clicker.ts new file mode 100644 index 0000000..4055055 --- /dev/null +++ b/src/ui/Clicker.ts @@ -0,0 +1,178 @@ +import Phaser from 'phaser'; +import type { EventBus } from '../core/EventBus'; +import { UI, chunkyButton, font, panel } from './style'; + +export interface ClickerSfx { + clickerClunk(): void; +} + +export interface ClickerOptions { + x: number; + y: number; + sfx?: ClickerSfx; + depth?: number; +} + +const BODY_W = 52; +const BODY_H = 34; + +// Recessed digit window, in body-local coords. +const WIN_X = 0; +const WIN_Y = -9; +const WIN_W = 34; +const WIN_H = 15; + +const DIGIT_SPACING = 10; +const ROLL_MS = 120; +// Slide distance: a glyph must clear the window before the mask stops hiding it. +const ROLL_TRAVEL = WIN_H; + +const MAX_COUNT = 999; + +interface DigitSlot { + readonly holder: Phaser.GameObjects.Container; + current: Phaser.GameObjects.Text; + incoming: Phaser.GameObjects.Text | null; + t: number; +} + +/** + * The handheld tally clicker. It owns the SHOWN number only — how many people + * are actually inside is somebody else's problem, which is the entire joke. IN + * is fat and obvious, OUT is small and easy to forget. + */ +export class Clicker { + private readonly scene: Phaser.Scene; + private readonly bus: EventBus; + private readonly sfx: ClickerSfx | undefined; + private readonly root: Phaser.GameObjects.Container; + private readonly maskGfx: Phaser.GameObjects.Graphics; + private readonly slots: DigitSlot[] = []; + private shown = 0; + + constructor(scene: Phaser.Scene, bus: EventBus, opts: ClickerOptions) { + this.scene = scene; + this.bus = bus; + this.sfx = opts.sfx; + + this.root = scene.add.container(opts.x, opts.y).setDepth(opts.depth ?? 900); + + const body = panel(scene, 0, 0, BODY_W, BODY_H, UI.grime); + const well = scene.add.rectangle(WIN_X, WIN_Y, WIN_W, WIN_H, UI.ink).setStrokeStyle(1, UI.panelLip); + this.root.add([body, well]); + + // Geometry mask lives in world space, so the widget is not meant to move + // after construction — nothing in the door scene moves it. + this.maskGfx = scene.make.graphics({}, false); + this.maskGfx.fillStyle(0xffffff); + this.maskGfx.fillRect( + opts.x + WIN_X - WIN_W / 2, + opts.y + WIN_Y - WIN_H / 2, + WIN_W, + WIN_H, + ); + const mask = this.maskGfx.createGeometryMask(); + + for (let i = 0; i < 3; i++) { + const holder = scene.add.container(WIN_X + (i - 1) * DIGIT_SPACING, WIN_Y); + holder.setMask(mask); + const current = this.makeGlyph('0'); + holder.add(current); + this.root.add(holder); + this.slots.push({ holder, current, incoming: null, t: 0 }); + } + + this.root.add( + chunkyButton(scene, -12, 10, 'IN', { + w: 26, + h: 13, + fill: UI.ok, + fontSize: 9, + onClick: () => this.bump(1), + }), + ); + this.root.add( + chunkyButton(scene, 14, 11, 'out', { + w: 18, + h: 9, + fill: UI.panelLip, + textColour: UI.dim, + fontSize: 6, + onClick: () => this.bump(-1), + }), + ); + } + + get count(): number { + return this.shown; + } + + update(deltaMs: number): void { + for (const slot of this.slots) { + const incoming = slot.incoming; + if (!incoming) continue; + slot.t += deltaMs; + const p = Math.min(1, slot.t / ROLL_MS); + const e = Phaser.Math.Easing.Quadratic.Out(p); + slot.current.y = -ROLL_TRAVEL * e; + incoming.y = ROLL_TRAVEL * (1 - e); + if (p >= 1) this.settle(slot); + } + } + + destroy(): void { + for (const slot of this.slots) slot.holder.clearMask(); + this.maskGfx.destroy(); + this.root.destroy(); + } + + private makeGlyph(glyph: string): Phaser.GameObjects.Text { + return this.scene.add.text(0, 0, glyph, font(12, UI.neonGreen)).setOrigin(0.5); + } + + /** + * The clunk is mechanical — it fires even at the stops. The event does not: + * core/meters tracks clickerShown from the same event, so a press that moves + * nothing must not move it either. + */ + private bump(delta: number): void { + this.sfx?.clickerClunk(); + const next = Phaser.Math.Clamp(this.shown + delta, 0, MAX_COUNT); + if (next === this.shown) return; + this.shown = next; + this.bus.emit('door:clicker', { direction: delta > 0 ? 'in' : 'out' }); + this.render(next); + } + + private render(value: number): void { + const digits = value.toString().padStart(3, '0'); + for (let i = 0; i < this.slots.length; i++) { + const slot = this.slots[i]; + const glyph = digits[i]; + if (!slot || glyph === undefined) continue; + const settled = slot.incoming ?? slot.current; + if (settled.text === glyph) continue; + this.roll(slot, glyph); + } + } + + private roll(slot: DigitSlot, glyph: string): void { + // A press mid-roll snaps the in-flight glyph home rather than stacking. + if (slot.incoming) this.settle(slot); + const incoming = this.makeGlyph(glyph); + incoming.y = ROLL_TRAVEL; + slot.holder.add(incoming); + slot.incoming = incoming; + slot.t = 0; + } + + private settle(slot: DigitSlot): void { + const incoming = slot.incoming; + if (!incoming) return; + slot.current.destroy(); + incoming.y = 0; + slot.current = incoming; + slot.incoming = null; + slot.t = 0; + } +} diff --git a/src/ui/DialogueBox.ts b/src/ui/DialogueBox.ts new file mode 100644 index 0000000..e88d351 --- /dev/null +++ b/src/ui/DialogueBox.ts @@ -0,0 +1,288 @@ +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; + } +} diff --git a/src/ui/JuiceDemoScene.ts b/src/ui/JuiceDemoScene.ts new file mode 100644 index 0000000..4596b9e --- /dev/null +++ b/src/ui/JuiceDemoScene.ts @@ -0,0 +1,564 @@ +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(); + } +} diff --git a/src/ui/MeterHud.ts b/src/ui/MeterHud.ts new file mode 100644 index 0000000..70e81f0 --- /dev/null +++ b/src/ui/MeterHud.ts @@ -0,0 +1,250 @@ +import Phaser from 'phaser'; +import { UI, font } from './style'; +import type { EventBus } from '../core/EventBus'; + +export interface MeterHudOptions { + x?: number; + y?: number; + depth?: number; +} + +const BAR_X = 30; +const BAR_W = 84; +const BAR_H = 7; +const ROW_H = 11; +const SEGMENTS = 10; +const SEG_GAP = 1; +const SEG_W = (BAR_W - SEG_GAP * (SEGMENTS - 1)) / SEGMENTS; +const SLOT = 12; +const SLOT_GAP = 3; +const HEAT_SLOTS = 3; + +// Below this the neon sign starts dying; above it the bar is rock steady. +const VIBE_FLICKER_BELOW = 25; +// Above this the crowd strip starts breathing. +const AGGRO_PULSE_ABOVE = 70; + +const AGGRO_COOL = 0x2f6f8f; + +const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v); + +function mix(a: number, b: number, t: number): number { + const c = Phaser.Display.Color.Interpolate.ColorWithColor( + Phaser.Display.Color.ValueToColor(a), + Phaser.Display.Color.ValueToColor(b), + 100, + Math.round(clamp01(t) * 100), + ); + return Phaser.Display.Color.GetColor(c.r, c.g, c.b); +} + +// Frame-rate independent ease: same settle time at 30fps as at 144. +const approach = (cur: number, target: number, deltaMs: number, tauMs: number): number => + target + (cur - target) * Math.exp(-deltaMs / tauMs); + +interface HeatSlot { + mark: Phaser.GameObjects.Container; + scale: number; +} + +/** + * Vibe / Aggro / Hype / Heat readout. Read-only: it listens to `meters:changed` + * and `heat:strike` and never emits or mutates anything. Each meter gets its own + * visual language so a glance tells you which number moved. + */ +export class MeterHud { + private readonly root: Phaser.GameObjects.Container; + private readonly offs: Array<() => void> = []; + + private vibe = 50; + private vibeShown = 50; + private aggro = 0; + private aggroShown = 0; + private hype = 1; + private hypeScale = 1; + private strikes = 0; + + // Accumulated phases — deterministic stand-in for noise (no Math.random). + private flickerPhase = 0; + private pulsePhase = 0; + + private readonly vibeGlow: Phaser.GameObjects.Rectangle; + private readonly vibeFill: Phaser.GameObjects.Rectangle; + private readonly aggroStrip: Phaser.GameObjects.Container; + private readonly segs: Phaser.GameObjects.Rectangle[] = []; + private readonly hypeBadge: Phaser.GameObjects.Container; + private readonly hypeText: Phaser.GameObjects.Text; + private readonly slots: HeatSlot[] = []; + + constructor(scene: Phaser.Scene, bus: EventBus, opts: MeterHudOptions = {}) { + const rowY = (i: number): number => i * ROW_H; + this.root = scene.add + .container(opts.x ?? 6, opts.y ?? 6) + .setDepth(opts.depth ?? 1000) + .setScrollFactor(0); + + this.root.add( + scene.add + .rectangle(-4, -4, BAR_X + BAR_W + 8, ROW_H * 4 + 6, UI.ink, 0.55) + .setOrigin(0, 0) + .setStrokeStyle(1, UI.panelLip, 0.4), + ); + + const label = (text: string, row: number): Phaser.GameObjects.Text => + scene.add.text(0, rowY(row), text, font(7, UI.paper)).setAlpha(0.55); + this.root.add([label('VIBE', 0), label('AGGRO', 1), label('HYPE', 2), label('HEAT', 3)]); + + // --- Vibe: neon sign. Glow is just a fatter, fainter copy of the tube. + const vibeMidY = rowY(0) + BAR_H / 2; + this.root.add( + scene.add.rectangle(BAR_X, vibeMidY, BAR_W, BAR_H, UI.ink, 0.7).setOrigin(0, 0.5), + ); + this.vibeGlow = scene.add + .rectangle(BAR_X, vibeMidY, BAR_W, BAR_H + 5, UI.neonGreen, 0.18) + .setOrigin(0, 0.5); + this.vibeFill = scene.add + .rectangle(BAR_X, vibeMidY, BAR_W, BAR_H - 2, UI.neonGreen) + .setOrigin(0, 0.5); + this.root.add([this.vibeGlow, this.vibeFill]); + + // --- Aggro: segmented crowd-temperature strip, cool on the left. + this.aggroStrip = scene.add.container(BAR_X, rowY(1) + BAR_H / 2); + for (let i = 0; i < SEGMENTS; i++) { + const seg = scene.add.rectangle( + i * (SEG_W + SEG_GAP) + SEG_W / 2, + 0, + SEG_W, + BAR_H, + mix(AGGRO_COOL, UI.danger, i / (SEGMENTS - 1)), + ); + this.segs.push(seg); + this.aggroStrip.add(seg); + } + this.root.add(this.aggroStrip); + + // --- Hype: a bonus badge, not a bar. It only ever multiplies good news. + this.hypeBadge = scene.add.container(BAR_X + 14, rowY(2) + BAR_H / 2); + this.hypeText = scene.add.text(0, 0, 'x1.0', font(8, UI.kebabAmber)).setOrigin(0.5); + this.hypeBadge.add([ + scene.add + .rectangle(0, 0, 28, 10, UI.kebabAmber, 0.16) + .setStrokeStyle(1, UI.kebabAmber, 0.7), + this.hypeText, + ]); + this.root.add(this.hypeBadge); + + // --- Heat: three licence-stamp slots. The third is the one that shuts you. + for (let i = 0; i < HEAT_SLOTS; i++) { + const final = i === HEAT_SLOTS - 1; + const cx = BAR_X + i * (SLOT + SLOT_GAP) + SLOT / 2; + const cy = rowY(3) + BAR_H / 2; + this.root.add( + scene.add + .rectangle(cx, cy, SLOT, SLOT, UI.ink, 0.35) + .setStrokeStyle(1, UI.paper, final ? 0.55 : 0.3), + ); + + const mark = scene.add.container(cx, cy).setVisible(false); + const crossColour = final ? UI.paper : UI.danger; + mark.add(scene.add.rectangle(0, 0, SLOT + 2, SLOT + 2, UI.danger, final ? 0.95 : 0.3)); + for (const angle of [45, -45]) { + mark.add( + scene.add + .rectangle(0, 0, SLOT + 4, final ? 3 : 2, crossColour) + .setAngle(angle) + .setAlpha(final ? 1 : 0.9), + ); + } + if (final) { + mark.add( + scene.add.rectangle(0, 0, SLOT + 3, SLOT + 3, 0x000000, 0).setStrokeStyle(2, UI.danger), + ); + mark.setAngle(-8); + } + this.root.add(mark); + this.slots.push({ mark, scale: 1 }); + } + + this.offs.push( + bus.on('meters:changed', ({ vibe, aggro, hype }) => { + this.vibe = vibe; + this.aggro = aggro; + if (hype > this.hype) this.hypeScale = 1.7; + this.hype = hype; + this.hypeText.setText(`x${hype.toFixed(1)}`); + }), + bus.on('heat:strike', () => { + const slot = this.slots[this.strikes]; + this.strikes++; + if (!slot) return; + slot.scale = 2.4; + slot.mark.setVisible(true); + }), + ); + + this.redraw(); + } + + update(deltaMs: number): void { + this.vibeShown = approach(this.vibeShown, this.vibe, deltaMs, 90); + this.aggroShown = approach(this.aggroShown, this.aggro, deltaMs, 110); + this.hypeScale = approach(this.hypeScale, 1, deltaMs, 80); + + const dying = clamp01((VIBE_FLICKER_BELOW - this.vibeShown) / VIBE_FLICKER_BELOW); + this.flickerPhase += deltaMs * 0.006 * (1 + dying * 3); + const hot = clamp01((this.aggroShown - AGGRO_PULSE_ABOVE) / (100 - AGGRO_PULSE_ABOVE)); + this.pulsePhase += deltaMs * 0.004 * (1 + hot * 2.5); + + for (const slot of this.slots) { + slot.scale = approach(slot.scale, 1, deltaMs, 70); + slot.mark.setScale(slot.scale); + } + + this.redraw(); + } + + private redraw(): void { + const vibeT = clamp01(this.vibeShown / 100); + const colour = mix(UI.neonPink, UI.neonGreen, vibeT); + const lit = Math.max(0.001, BAR_W * vibeT); + this.vibeFill.setSize(lit, BAR_H - 2).setFillStyle(colour); + this.vibeGlow.setSize(lit + 4, BAR_H + 5).setFillStyle(colour); + + const dying = clamp01((VIBE_FLICKER_BELOW - this.vibeShown) / VIBE_FLICKER_BELOW); + const a = this.neonAlpha(dying); + this.vibeFill.setAlpha(a); + this.vibeGlow.setAlpha(0.18 * a); + + const litSegs = (this.aggroShown / 100) * SEGMENTS; + const hot = clamp01((this.aggroShown - AGGRO_PULSE_ABOVE) / (100 - AGGRO_PULSE_ABOVE)); + const breath = 0.5 + 0.5 * Math.sin(this.pulsePhase); + const pulse = 1 - 0.4 * hot * breath; + for (let i = 0; i < this.segs.length; i++) { + const seg = this.segs[i]; + if (!seg) continue; + const on = clamp01(litSegs - i); + seg.setAlpha(on > 0 ? (0.35 + 0.65 * on) * pulse : 0.14); + } + this.aggroStrip.setScale(1, 1 + 0.28 * hot * breath); + + this.hypeBadge.setScale(this.hypeScale); + } + + /** + * Three sines at incommensurate rates stand in for noise; the threshold rises + * with `dying`, so blackouts get more frequent as the sign gives up. + */ + private neonAlpha(dying: number): number { + if (dying <= 0) return 1; + const p = this.flickerPhase; + const n = 0.5 * Math.sin(p) + 0.3 * Math.sin(p * 2.37 + 1.1) + 0.2 * Math.sin(p * 5.13 + 2.7); + if (n < -0.8 + dying * 1.1) return 0.12; + return 1 - 0.15 * dying * (0.5 + 0.5 * Math.sin(p * 3.1)); + } + + destroy(): void { + for (const off of this.offs) off(); + this.offs.length = 0; + this.root.destroy(); + } +} diff --git a/src/ui/Phone.ts b/src/ui/Phone.ts new file mode 100644 index 0000000..4a1a170 --- /dev/null +++ b/src/ui/Phone.ts @@ -0,0 +1,396 @@ +import Phaser from 'phaser'; +import type { EventBus } from '../core/EventBus'; +import { UI, font, panel } from './style'; +import './juiceEvents'; + +// The player's only line to management. Dazza is a man who is frightened of the +// owner, so every buzz is a small emergency. The phone is deliberately fiddly: +// looking at it costs you attention at the door, which is exactly the point — +// see `door:phoneTheatre`. + +const W = 640; +const H = 360; + +const PHONE_W = 22; +const PHONE_H = 38; + +const PANEL_W = 150; +const PANEL_H = 180; +const PAD = 6; +const BUBBLE_MAX_W = 104; +const BUBBLE_GAP = 4; +const HEADER_H = 13; +const DOTS_H = 12; + +const TOAST_W = 112; +const TOAST_H = 26; +const TOAST_MS = 3500; +const TOAST_CHARS = 40; + +const BUZZ_MS = 400; +const BUZZ_AMP = 2.4; +const BUZZ_CYCLES = 9; +const TYPING_MS = 700; +const DOTS_STEP_MS = 220; + +interface ThreadMessage { + from: 'dazza' | 'you'; + text: string; + carriedRule: boolean; + /** clock-min the text landed, if a clock:tick has been seen yet */ + clockMin?: number; +} + +export interface PhoneSfx { + phoneBuzz(): void; +} + +export interface PhoneOptions { + x: number; + y: number; + sfx?: PhoneSfx; + depth?: number; +} + +/** Minutes since 21:00 -> venue-clock label. */ +function clockLabel(clockMin: number): string { + const total = (21 * 60 + Math.max(0, Math.floor(clockMin))) % (24 * 60); + const h = Math.floor(total / 60); + const m = total % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +} + +export class Phone { + private readonly scene: Phaser.Scene; + private readonly bus: EventBus; + private readonly sfx: PhoneSfx | undefined; + private readonly depth: number; + private readonly homeX: number; + private readonly homeY: number; + /** -1 when the phone sits on the right of the canvas: overlays open inward. */ + private readonly side: number; + + private readonly root: Phaser.GameObjects.Container; + private readonly screen: Phaser.GameObjects.Rectangle; + private readonly unreadDot: Phaser.GameObjects.Arc; + + private readonly threadRoot: Phaser.GameObjects.Container; + private readonly threadBody: Phaser.GameObjects.Container; + private readonly dots: Phaser.GameObjects.Text; + + private toast: Phaser.GameObjects.Container | undefined; + private toastMs = 0; + private toastTween: Phaser.Tweens.Tween | undefined; + + private readonly offs: Array<() => void> = []; + private readonly messages: ThreadMessage[] = []; + private pending: ThreadMessage | undefined; + private pendingMs = 0; + private dotsMs = 0; + + private open_ = false; + private openMs = 0; + private clockMin: number | undefined; + + private readonly buzzPhase = { v: 1 }; + private buzzTween: Phaser.Tweens.Tween | undefined; + private slideTween: Phaser.Tweens.Tween | undefined; + private readonly glowTween: Phaser.Tweens.Tween; + + constructor(scene: Phaser.Scene, bus: EventBus, opts: PhoneOptions) { + this.scene = scene; + this.bus = bus; + this.sfx = opts.sfx; + this.depth = opts.depth ?? 900; + this.homeX = opts.x; + this.homeY = opts.y; + this.side = opts.x > W / 2 ? -1 : 1; + + this.root = scene.add.container(this.homeX, this.homeY).setDepth(this.depth); + const body = scene.add.rectangle(0, 0, PHONE_W, PHONE_H, UI.grime).setStrokeStyle(1, UI.ink); + this.screen = scene.add.rectangle(0, -1, PHONE_W - 6, PHONE_H - 12, UI.ink); + const glow = scene.add.rectangle(0, -1, PHONE_W - 8, PHONE_H - 14, UI.neonGreen, 0.09); + const speaker = scene.add.rectangle(0, -PHONE_H / 2 + 4, 7, 1, UI.panelLip); + const homeKey = scene.add.circle(0, PHONE_H / 2 - 4, 2, UI.panelLip); + this.unreadDot = scene.add.circle(PHONE_W / 2 - 3, -PHONE_H / 2 + 3, 2.5, UI.neonPink).setVisible(false); + this.root.add([body, this.screen, glow, speaker, homeKey, this.unreadDot]); + + // Standby breathing — a dark screen that is completely still reads as a prop. + this.glowTween = scene.tweens.add({ targets: glow, alpha: 0.2, duration: 1800, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + + body.setInteractive({ useHandCursor: true }); + body.on('pointerup', () => this.toggle()); + + const anchor = this.threadAnchor(); + this.threadRoot = scene.add.container(anchor.x, anchor.y).setDepth(this.depth + 2).setVisible(false); + const chrome = panel(scene, 0, 0, PANEL_W, PANEL_H); + const title = scene.add.text(-PANEL_W / 2 + PAD, -PANEL_H / 2 + 4, 'DAZZA (MANAGEMENT)', font(7, UI.paper)); + title.setAlpha(0.55); + const rule = scene.add.rectangle(0, -PANEL_H / 2 + HEADER_H, PANEL_W - PAD * 2, 1, UI.panelLip, 0.7); + this.threadBody = scene.add.container(0, 0); + this.dots = scene.add + .text(-PANEL_W / 2 + PAD + 4, PANEL_H / 2 - PAD - 9, '', font(8, UI.paper)) + .setAlpha(0.6) + .setVisible(false); + this.threadRoot.add([chrome, title, rule, this.threadBody, this.dots]); + + this.offs.push(bus.on('dazza:text', ({ text, rule: dressRule }) => this.receive(text, dressRule !== undefined))); + this.offs.push(bus.on('clock:tick', ({ clockMin }) => { this.clockMin = clockMin; })); + } + + get isOpen(): boolean { + return this.open_; + } + + open(): void { + if (this.open_) return; + this.open_ = true; + this.openMs = 0; + this.unreadDot.setVisible(false); + this.screen.setFillStyle(UI.panel); + this.dismissToast(); + + const anchor = this.threadAnchor(); + this.threadRoot.setVisible(true).setPosition(anchor.x, anchor.y + 14).setAlpha(0); + this.slideTween?.remove(); + this.slideTween = this.scene.tweens.add({ + targets: this.threadRoot, + y: anchor.y, + alpha: 1, + duration: 160, + ease: 'Back.easeOut', + }); + this.layoutThread(); + } + + close(): void { + if (!this.open_) return; + this.open_ = false; + this.screen.setFillStyle(UI.ink); + this.flushPending(); + + const anchor = this.threadAnchor(); + this.slideTween?.remove(); + this.slideTween = this.scene.tweens.add({ + targets: this.threadRoot, + y: anchor.y + 14, + alpha: 0, + duration: 130, + ease: 'Quad.easeIn', + onComplete: () => this.threadRoot.setVisible(false), + }); + + // Standing at the door reading your phone while a queue waits is the purest + // expression of power a bouncer has. The door scene pays hype for it. + this.bus.emit('door:phoneTheatre', { durationMs: Math.round(this.openMs) }); + } + + toggle(): void { + if (this.open_) this.close(); + else this.open(); + } + + update(deltaMs: number): void { + if (this.open_) this.openMs += deltaMs; + + if (this.pending) { + this.pendingMs += deltaMs; + this.dotsMs += deltaMs; + const step = Math.floor(this.dotsMs / DOTS_STEP_MS) % 3; + this.dots.setText('.'.repeat(step + 1)); + if (this.pendingMs >= TYPING_MS) { + this.messages.push(this.pending); + this.pending = undefined; + this.dots.setVisible(false); + this.layoutThread(); + } + } + + if (this.toast) { + this.toastMs += deltaMs; + if (this.toastMs >= TOAST_MS) this.dismissToast(); + } + } + + destroy(): void { + for (const off of this.offs) off(); + this.offs.length = 0; + this.buzzTween?.remove(); + this.slideTween?.remove(); + // Tweens outlive their targets: destroy() outside a scene shutdown leaves + // anything still running writing to a dead GameObject every frame. + this.glowTween.remove(); + this.dismissToast(); + this.threadRoot.destroy(true); + this.root.destroy(true); + } + + private receive(text: string, carriedRule: boolean): void { + const msg: ThreadMessage = { from: 'dazza', text, carriedRule, ...(this.clockMin !== undefined ? { clockMin: this.clockMin } : {}) }; + this.buzz(); + this.sfx?.phoneBuzz(); + + if (this.open_) { + // Land any earlier pending line first so nothing gets swallowed by a + // second buzz arriving mid-typing. + if (this.pending) this.messages.push(this.pending); + this.pending = msg; + this.pendingMs = 0; + this.dotsMs = 0; + this.dots.setText('.').setVisible(true); + this.layoutThread(); + return; + } + + this.deliverClosed(msg); + } + + private deliverClosed(msg: ThreadMessage): void { + this.messages.push(msg); + if (msg.carriedRule) this.unreadDot.setVisible(true); + this.showToast(msg.text); + } + + /** + * Closing mid-typing has to land the held message now. Left in `pending` it + * would arrive behind anything received meanwhile — out of order, and with no + * toast or unread dot to announce it to a player who has looked away. + */ + private flushPending(): void { + const msg = this.pending; + if (!msg) return; + this.pending = undefined; + this.pendingMs = 0; + this.dots.setVisible(false); + this.deliverClosed(msg); + } + + /** Vibration driven off a tweened phase so it is frame-rate stable and seedless. */ + private buzz(): void { + this.buzzTween?.remove(); + this.buzzPhase.v = 0; + this.buzzTween = this.scene.tweens.add({ + targets: this.buzzPhase, + v: 1, + duration: BUZZ_MS, + onUpdate: () => { + const t = this.buzzPhase.v; + const amp = BUZZ_AMP * (1 - t); + this.root.setPosition( + this.homeX + Math.sin(t * Math.PI * 2 * BUZZ_CYCLES) * amp, + this.homeY + Math.cos(t * Math.PI * 2 * (BUZZ_CYCLES + 4)) * amp * 0.55, + ); + }, + onComplete: () => this.root.setPosition(this.homeX, this.homeY), + }); + } + + private threadAnchor(): { x: number; y: number } { + const cx = Phaser.Math.Clamp(this.homeX, PANEL_W / 2 + 4, W - PANEL_W / 2 - 4); + const cy = Phaser.Math.Clamp(this.homeY - PHONE_H / 2 - 6 - PANEL_H / 2, PANEL_H / 2 + 4, H - PANEL_H / 2 - 4); + return { x: cx, y: cy }; + } + + private showToast(text: string): void { + this.dismissToast(); + const preview = text.length > TOAST_CHARS ? `${text.slice(0, TOAST_CHARS).trimEnd()}…` : text; + const targetX = Phaser.Math.Clamp( + this.homeX + this.side * (PHONE_W / 2 + 5 + TOAST_W / 2), + TOAST_W / 2 + 4, + W - TOAST_W / 2 - 4, + ); + const y = Phaser.Math.Clamp(this.homeY - 8, TOAST_H / 2 + 4, H - TOAST_H / 2 - 4); + + const c = panel(this.scene, targetX - this.side * 10, y, TOAST_W, TOAST_H).setDepth(this.depth + 1).setAlpha(0); + const from = this.scene.add.text(-TOAST_W / 2 + 5, -TOAST_H / 2 + 3, 'dazza', font(6, UI.neonPink)); + const body = this.scene.add.text(-TOAST_W / 2 + 5, -TOAST_H / 2 + 11, preview, { + ...font(7, UI.paper), + wordWrap: { width: TOAST_W - 10, useAdvancedWrap: true }, + }); + c.add([from, body]); + this.toast = c; + this.toastMs = 0; + this.toastTween = this.scene.tweens.add({ targets: c, x: targetX, alpha: 1, duration: 180, ease: 'Quad.easeOut' }); + } + + private dismissToast(): void { + this.toastTween?.remove(); + this.toastTween = undefined; + this.toast?.destroy(true); + this.toast = undefined; + this.toastMs = 0; + } + + private layoutThread(): void { + this.threadBody.removeAll(true); + if (!this.open_) return; + + const top = -PANEL_H / 2 + HEADER_H + 3; + const floor = PANEL_H / 2 - PAD - (this.pending ? DOTS_H : 0); + let cursor = floor; + + // Newest at the bottom: build upward and stop as soon as one won't fit. The + // newest is clipped to the interior instead of dropped — a text long enough + // to overflow the panel on its own would otherwise blank the whole thread. + let placed = 0; + for (let i = this.messages.length - 1; i >= 0; i--) { + const msg = this.messages[i]; + if (!msg) continue; + const bubble = this.buildBubble(msg, placed === 0 ? floor - top : Number.POSITIVE_INFINITY); + if (placed > 0 && cursor - bubble.height < top) { + bubble.container.destroy(true); + break; + } + placed++; + bubble.container.setY(cursor - bubble.height); + this.threadBody.add(bubble.container); + cursor -= bubble.height + BUBBLE_GAP; + } + } + + /** Container origin is the bubble's top-left; caller only sets Y. */ + private buildBubble( + msg: ThreadMessage, + maxHeight = Number.POSITIVE_INFINITY, + ): { container: Phaser.GameObjects.Container; height: number } { + const incoming = msg.from === 'dazza'; + const pad = 4; + const c = this.scene.add.container(0, 0); + + const text = this.scene.add.text(pad, pad, msg.text, { + ...font(7, incoming ? UI.ink : UI.paper), + wordWrap: { width: BUBBLE_MAX_W - pad * 2, useAdvancedWrap: true }, + }); + const w = Math.min(BUBBLE_MAX_W, Math.ceil(text.width) + pad * 2); + const stampH = msg.clockMin !== undefined ? 8 : 0; + let h = Math.ceil(text.height) + pad * 2; + if (h + stampH > maxHeight) { + // Fixed height crops the text canvas: the overflow is cut, not reflowed. + const fit = Math.max(pad * 2 + 1, Math.floor(maxHeight) - stampH); + text.setFixedSize(0, fit - pad * 2); + h = fit; + } + const bg = this.scene.add + .rectangle(0, 0, w, h, incoming ? UI.paper : UI.neonPink) + .setOrigin(0, 0) + .setStrokeStyle(1, UI.ink, 0.4); + c.add([bg, text]); + + let height = h; + if (msg.carriedRule) { + // Rule-bearing texts stay flagged forever — this is the one you get sacked + // for missing. + const badge = this.scene.add.text(w + 3, 0, 'NEW', font(6, UI.neonPink)); + c.add(badge); + } + if (msg.clockMin !== undefined) { + const stamp = this.scene.add.text(0, h + 1, clockLabel(msg.clockMin), font(6, UI.paper)); + stamp.setAlpha(0.4); + c.add(stamp); + height += 8; + } + + c.setX(incoming ? -PANEL_W / 2 + PAD : PANEL_W / 2 - PAD - w); + return { container: c, height }; + } +} diff --git a/src/ui/Stamp.ts b/src/ui/Stamp.ts new file mode 100644 index 0000000..75614ee --- /dev/null +++ b/src/ui/Stamp.ts @@ -0,0 +1,268 @@ +import Phaser from 'phaser'; +import { UI, font, shake } from './style'; + +// The stamp is the whole game in one gesture, so the timing is doing the acting: +// a wind-up above the target, 160ms of accelerating travel, a hard hit, then a +// 90ms dead hang before the lift. The hang is what makes it feel like a decision +// rather than an animation — do not shorten it. + +const HEAD_W = 54; +const HEAD_H = 22; + +const TRAVEL_MS = 160; +const HANG_MS = 90; +const RELEASE_MS = 180; +const RING_MS = 200; +const DUST_MS = 350; + +const DUST_COUNT = 9; +const SPLATS = 7; + +/** Head sits above the decal; ring and dust sit between them. */ +const RING_DEPTH_OFFSET = 5; +const HEAD_DEPTH_OFFSET = 10; + +export interface StampSfx { + stampSlam(): void; + stampInk(): void; +} + +export interface StampHandle { + /** The ink mark left behind. The caller owns it; destroy it when the patron leaves. */ + decal: Phaser.GameObjects.Container; +} + +export interface StampOptions { + /** Always scene/world coordinates — converted into `parent` space when one is given. */ + x: number; + y: number; + text?: string; + colour?: number; + depth?: number; + sfx?: StampSfx; + /** Parent for the decal, so the mark travels with the patron. Defaults to the scene root. */ + parent?: Phaser.GameObjects.Container; + /** Per-impression variation. Same seed => same mark. Defaults to a hash of `text`. */ + seed?: number; + onSlam?: () => void; + onDone?: () => void; +} + +/** + * FNV-1a over the label so DENIED and ADMITTED get different splatter, stably; the + * caller's seed is folded in after so two DENIEDs are not the same printed mess. + */ +function hashText(text: string, seed?: number): number { + let h = 2166136261; + for (const ch of text) { + h ^= ch.charCodeAt(0); + h = Math.imul(h, 16777619); + } + if (seed !== undefined) { + let s = seed >>> 0; + for (let i = 0; i < 4; i++) { + h ^= s & 0xff; + h = Math.imul(h, 16777619); + s >>>= 8; + } + } + return (h >>> 0) % 4096; +} + +/** Hash noise in place of Math.random — same label always prints the same mess. */ +function sprinkle(seed: number, i: number): number { + const s = Math.sin(seed * 12.9898 + i * 78.233) * 43758.5453; + return s - Math.floor(s); +} + +/** The rubber head: dark body, inked face, knurled grip on top. */ +function buildHead( + scene: Phaser.Scene, + colour: number, + label: string, +): Phaser.GameObjects.Container { + const head = scene.add.container(0, 0); + + const body = scene.add.graphics(); + body.fillStyle(UI.ink, 1); + body.fillRoundedRect(-HEAD_W / 2, -HEAD_H / 2, HEAD_W, HEAD_H, 3); + body.fillStyle(UI.panelLip, 1); + body.fillRect(-10, -HEAD_H / 2 - 8, 20, 8); + body.fillStyle(UI.panel, 1); + body.fillRect(-14, -HEAD_H / 2 - 11, 28, 4); + + const face = scene.add.graphics(); + face.fillStyle(colour, 1); + face.fillRoundedRect(-HEAD_W / 2 + 3, -HEAD_H / 2 + 4, HEAD_W - 6, HEAD_H - 7, 2); + + const text = scene.add.text(0, 1, label, font(8, UI.paper)).setOrigin(0.5).setLetterSpacing(1); + + head.add([body, face, text]); + return head; +} + +/** The mark that outlives the gesture: border, label, and uneven ink. */ +function fillDecal( + scene: Phaser.Scene, + decal: Phaser.GameObjects.Container, + colour: number, + label: string, + seed: number, +): void { + // Never quite square to the card — a machine would print it straight. + decal.setRotation((sprinkle(seed, 0) - 0.5) * 0.16); + + const border = scene.add.graphics(); + border.lineStyle(2, colour, 0.85); + border.strokeRoundedRect(-HEAD_W / 2 + 2, -HEAD_H / 2 + 3, HEAD_W - 4, HEAD_H - 6, 2); + decal.add(border); + + // Smudge under the crisp pass: one bad impression, printed twice. + decal.add( + scene.add.text(1, 2, label, font(9, colour)).setOrigin(0.5).setLetterSpacing(1).setAlpha(0.35), + ); + decal.add(scene.add.text(0, 1, label, font(9, colour)).setOrigin(0.5).setLetterSpacing(1).setAlpha(0.92)); + + // Over-inked pooling near the edges plus a few thrown specks. + for (let i = 0; i < SPLATS; i++) { + const a = (i / SPLATS) * Math.PI * 2 + sprinkle(seed, 100 + i); + const r = 12 + sprinkle(seed, 200 + i) * 16; + const size = 1 + Math.round(sprinkle(seed, 300 + i) * 2); + decal.add( + scene.add + .circle(Math.cos(a) * r, Math.sin(a) * r * 0.5, size, colour, 1) + .setAlpha(0.28 + sprinkle(seed, 400 + i) * 0.35), + ); + } +} + +/** Shockwave: a flattened ring, because the ink lands on a surface, not in air. */ +function spawnRing(scene: Phaser.Scene, opts: StampOptions, colour: number, depth: number): void { + const ring = scene.add + .circle(opts.x, opts.y, 10, colour, 0) + .setStrokeStyle(2, colour, 0.9) + .setDepth(depth) + .setScale(0.4, 0.25); + + scene.tweens.add({ + targets: ring, + scaleX: 2.4, + scaleY: 1.3, + alpha: 0, + duration: RING_MS, + ease: 'Cubic.easeOut', + onComplete: () => ring.destroy(), + }); +} + +/** Fixed angular steps — a random spray reads as noise, an even ring reads as force. */ +function spawnDust(scene: Phaser.Scene, opts: StampOptions, colour: number, seed: number, depth: number): void { + for (let i = 0; i < DUST_COUNT; i++) { + const a = (i / DUST_COUNT) * Math.PI * 2; + const dist = 13 + sprinkle(seed, 500 + i) * 11; + const size = 1 + Math.round(sprinkle(seed, 600 + i)); + // Alternating grit and ink so the burst reads as both dust and spatter. + const tint = i % 2 === 0 ? UI.paper : colour; + + const p = scene.add + .rectangle(opts.x, opts.y, size, size, tint, 0.75) + .setDepth(depth); + + scene.tweens.add({ + targets: p, + x: opts.x + Math.cos(a) * dist, + y: opts.y + Math.sin(a) * dist * 0.45 + 4, + alpha: 0, + duration: DUST_MS, + ease: 'Quad.easeOut', + onComplete: () => p.destroy(), + }); + } +} + +/** + * Slam a stamp onto world (x, y). The head, ring and dust clean themselves up; the + * returned decal is the caller's to destroy — nothing else will. It is empty until + * the head lands, so the returned container is safe to reparent or destroy early. + * Colour and label are pure inputs — the ADMITTED variant is the same code path. + */ +export function stamp(scene: Phaser.Scene, opts: StampOptions): StampHandle { + const label = opts.text ?? 'DENIED'; + const colour = opts.colour ?? UI.danger; + const depth = opts.depth ?? 0; + const seed = hashText(label, opts.seed); + + const decal = scene.add.container(0, 0).setDepth(depth); + if (opts.parent) opts.parent.add(decal); + let decalAlive = true; + decal.once(Phaser.GameObjects.Events.DESTROY, () => { + decalAlive = false; + }); + + const head = buildHead(scene, colour, label); + head + .setDepth(depth + HEAD_DEPTH_OFFSET) + .setPosition(opts.x + 14, opts.y - 70) + .setScale(1.6) + .setRotation(-0.35) + .setAlpha(0.35); + + const impact = (): void => { + shake(scene, 4, 120); + opts.sfx?.stampSlam(); + opts.sfx?.stampInk(); + opts.onSlam?.(); + + if (decalAlive) { + // Placed at impact, not at call time, so a moving parent stamps where the ink hit. + const local = opts.parent + ? opts.parent.getWorldTransformMatrix().applyInverse(opts.x, opts.y) + : { x: opts.x, y: opts.y }; + decal.setPosition(local.x, local.y); + fillDecal(scene, decal, colour, label, seed); + } + spawnRing(scene, opts, colour, depth + RING_DEPTH_OFFSET); + spawnDust(scene, opts, colour, seed, depth + RING_DEPTH_OFFSET); + + // `delay` is the hang — nothing moves, and that stillness is the punctuation. + scene.tweens.add({ + targets: head, + y: opts.y - 26, + alpha: 0, + duration: RELEASE_MS, + delay: HANG_MS, + ease: 'Quad.easeOut', + onComplete: () => { + head.destroy(); + opts.onDone?.(); + }, + }); + }; + + // Split easings per axis: the horizontal settles early while the vertical is + // still accelerating, which bends the path into an arc without a path object. + scene.tweens.add({ + targets: head, + x: opts.x, + duration: TRAVEL_MS, + ease: 'Quad.easeOut', + }); + scene.tweens.add({ + targets: head, + alpha: 1, + duration: TRAVEL_MS * 0.6, + ease: 'Linear', + }); + scene.tweens.add({ + targets: head, + y: opts.y, + rotation: 0, + scaleX: 1, + scaleY: 1, + duration: TRAVEL_MS, + ease: 'Back.easeIn', + onComplete: impact, + }); + + return { decal }; +} diff --git a/src/ui/style.ts b/src/ui/style.ts index 39d59d3..9b5d3d3 100644 --- a/src/ui/style.ts +++ b/src/ui/style.ts @@ -85,11 +85,25 @@ export function chunkyButton( shadow.setAlpha(down ? 0.2 : 0.8); face.setFillStyle(down ? Phaser.Display.Color.ValueToColor(fill).darken(20).color : fill); }; - face.on('pointerdown', () => press(true)); - face.on('pointerout', () => press(false)); - face.on('pointerup', () => { + // Phaser fires 'pointerup' on any release over the object, even one whose + // 'pointerdown' landed elsewhere. Arm on our own down and disarm the moment the + // pointer leaves, so a stray release can't phantom-click (clicker tally is load-bearing). + let armed = false; + face.on('pointerdown', () => { + armed = true; + press(true); + }); + // Leaving the face is the only way to release elsewhere, so this covers the + // press-drag-away-release case too. + face.on('pointerout', () => { + armed = false; press(false); - opts.onClick(); + }); + face.on('pointerup', () => { + const fire = armed; + armed = false; + press(false); + if (fire) opts.onClick(); }); return c; } diff --git a/src/ui/typo.ts b/src/ui/typo.ts index e5cad4b..95f450c 100644 --- a/src/ui/typo.ts +++ b/src/ui/typo.ts @@ -19,6 +19,17 @@ const NEIGHBOURS: Record = { /** Vowels get stretched when someone is really going for it. */ const VOWELS = 'aeiou'; +/** + * Clamp to 0..1, treating a non-finite input as 0. + * + * A NaN reaching `text.y` makes Phaser silently render nothing, so it must never + * leave this module. Callers guard their own frame deltas — this is the backstop, + * not the fix. + */ +function clamp01(n: number): number { + return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0; +} + export interface TypoOptions { /** 0..1 — 0 leaves text untouched, 1 is barely language. */ drunkenness: number; @@ -36,7 +47,7 @@ export interface TypoOptions { * - stretched vowels ("yeahhh") */ export function drunkify(text: string, { drunkenness, rng }: TypoOptions): string { - const d = Math.max(0, Math.min(1, drunkenness)); + const d = clamp01(drunkenness); if (d <= 0) return text; // Below ~0.25 people type fine. Above that it ramps quickly. @@ -84,14 +95,16 @@ export function drunkify(text: string, { drunkenness, rng }: TypoOptions): strin */ export function typedLength(text: string, elapsedMs: number, charMs: number, drunkenness = 0): number { if (charMs <= 0) return text.length; - const slow = 1 + Math.max(0, Math.min(1, drunkenness)) * 1.2; - const raw = elapsedMs / (charMs * slow); - return Math.max(0, Math.min(text.length, Math.floor(raw))); + const slow = 1 + clamp01(drunkenness) * 1.2; + const raw = Math.floor(elapsedMs / (charMs * slow)); + if (Number.isNaN(raw)) return 0; + return Math.max(0, Math.min(text.length, raw)); } /** Per-character vertical wobble (px) for drunk render mode. Deterministic. */ export function wobbleAt(index: number, drunkenness: number, phase: number): number { - const amp = Math.max(0, Math.min(1, drunkenness)) * 1.6; + const amp = clamp01(drunkenness) * 1.6; if (amp <= 0) return 0; - return Math.sin(phase + index * 0.9) * amp; + const y = Math.sin(phase + index * 0.9) * amp; + return Number.isNaN(y) ? 0 : y; } diff --git a/tests/filterCurve.test.ts b/tests/filterCurve.test.ts new file mode 100644 index 0000000..f100b2f --- /dev/null +++ b/tests/filterCurve.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { + CLOSE_SWEEP_MS, + DOOR_CUTOFF_HZ, + DOOR_GAIN, + FLOOR_CUTOFF_HZ, + FLOOR_GAIN, + OPEN_SWEEP_MS, + cutoffAt, + cutoffCurve, + cutoffFor, + gainAt, + gainFor, + sweepMsFor, +} from '../src/audio/filterCurve'; + +describe('location constants', () => { + it('maps each location to its cutoff and gain', () => { + expect(cutoffFor('door')).toBe(DOOR_CUTOFF_HZ); + expect(cutoffFor('floor')).toBe(FLOOR_CUTOFF_HZ); + expect(gainFor('door')).toBe(DOOR_GAIN); + expect(gainFor('floor')).toBe(FLOOR_GAIN); + }); + + it('keeps the door muffled and quieter than the floor', () => { + expect(cutoffFor('door')).toBeLessThan(cutoffFor('floor')); + expect(gainFor('door')).toBeLessThan(gainFor('floor')); + }); + + it('opens faster than it closes', () => { + expect(sweepMsFor('floor')).toBe(OPEN_SWEEP_MS); + expect(sweepMsFor('door')).toBe(CLOSE_SWEEP_MS); + expect(sweepMsFor('floor')).toBeLessThan(sweepMsFor('door')); + }); +}); + +describe('cutoffAt', () => { + it('hits the endpoints exactly', () => { + expect(cutoffAt(0, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6); + expect(cutoffAt(1, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6); + expect(cutoffAt(0, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6); + expect(cutoffAt(1, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6); + }); + + it('clamps t outside 0..1 so callers can pass raw elapsed/duration', () => { + expect(cutoffAt(-3, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6); + expect(cutoffAt(-0.001, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(DOOR_CUTOFF_HZ, 6); + expect(cutoffAt(1.5, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6); + expect(cutoffAt(9000, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ)).toBeCloseTo(FLOOR_CUTOFF_HZ, 6); + }); + + it('sits at the geometric mean halfway, not the arithmetic one', () => { + // The whole point: a linear ramp would already be at ~9kHz by the midpoint and + // the door would sound like someone nudged a fader. + const geometric = Math.sqrt(DOOR_CUTOFF_HZ * FLOOR_CUTOFF_HZ); // ~2121Hz + const arithmetic = (DOOR_CUTOFF_HZ + FLOOR_CUTOFF_HZ) / 2; // ~9125Hz + const mid = cutoffAt(0.5, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); + + expect(mid).toBeCloseTo(geometric, 3); + expect(mid).toBeCloseTo(2121.32, 1); + expect(mid).toBeLessThan(arithmetic / 4); + }); + + it('keeps the brightness late — three quarters through is still under half the range', () => { + const oneQuarter = cutoffAt(0.25, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); + const threeQuarters = cutoffAt(0.75, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); + + // Anchored to measured values, not to the formula — otherwise any curve of the + // same algebraic shape passes and a regression in the clamp goes unnoticed. + expect(oneQuarter).toBeCloseTo(728.24, 1); + expect(threeQuarters).toBeCloseTo(6179.3, 1); + expect(threeQuarters).toBeLessThan(FLOOR_CUTOFF_HZ / 2); + }); + + it('covers equal ratios over equal steps of t', () => { + // The defining property of the sweep: every quarter multiplies the cutoff by the + // same factor, so the reveal accelerates in Hz while sounding even to the ear. + const at = (t: number) => cutoffAt(t, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); + const step = Math.pow(FLOOR_CUTOFF_HZ / DOOR_CUTOFF_HZ, 0.25); + + for (const t of [0, 0.25, 0.5, 0.75]) { + expect(at(t + 0.25) / at(t)).toBeCloseTo(step, 6); + } + // Equivalently: each sample is the geometric mean of its neighbours. + expect(at(0.75)).toBeCloseTo(Math.sqrt(at(0.5) * FLOOR_CUTOFF_HZ), 3); + }); + + it('rises monotonically while opening', () => { + let prev = -Infinity; + for (let i = 0; i <= 200; i++) { + const hz = cutoffAt(i / 200, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); + expect(hz).toBeGreaterThan(prev); + prev = hz; + } + }); + + it('falls monotonically while closing', () => { + let prev = Infinity; + for (let i = 0; i <= 200; i++) { + const hz = cutoffAt(i / 200, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ); + expect(hz).toBeLessThan(prev); + prev = hz; + } + }); + + it('stays inside the sweep range at every t', () => { + for (let i = 0; i <= 100; i++) { + const hz = cutoffAt(i / 100, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); + expect(hz).toBeGreaterThanOrEqual(DOOR_CUTOFF_HZ); + expect(hz).toBeLessThanOrEqual(FLOOR_CUTOFF_HZ); + } + }); + + it('is symmetric: closing retraces the opening sweep backwards', () => { + for (let i = 0; i <= 20; i++) { + const t = i / 20; + expect(cutoffAt(t, FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ)).toBeCloseTo( + cutoffAt(1 - t, DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ), + 6, + ); + } + }); +}); + +describe('gainAt', () => { + it('interpolates linearly between the endpoints', () => { + expect(gainAt(0, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(DOOR_GAIN, 6); + expect(gainAt(1, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(FLOOR_GAIN, 6); + expect(gainAt(0.5, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo((DOOR_GAIN + FLOOR_GAIN) / 2, 6); + expect(gainAt(0.25, 0, 1)).toBeCloseTo(0.25, 6); + expect(gainAt(0.25, 1, 0)).toBeCloseTo(0.75, 6); + }); + + it('clamps t outside 0..1', () => { + expect(gainAt(-2, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(DOOR_GAIN, 6); + expect(gainAt(4, DOOR_GAIN, FLOOR_GAIN)).toBeCloseTo(FLOOR_GAIN, 6); + }); +}); + +describe('cutoffCurve', () => { + it('defaults to 32 sampled points spanning the full sweep', () => { + const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); + expect(curve).toBeInstanceOf(Float32Array); + expect(curve.length).toBe(32); + expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3); + expect(curve[31]!).toBeCloseTo(FLOOR_CUTOFF_HZ, 0); + }); + + it('honours a custom point count', () => { + for (const points of [2, 8, 64, 129]) { + const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points); + expect(curve.length).toBe(points); + expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3); + expect(curve[points - 1]!).toBeCloseTo(FLOOR_CUTOFF_HZ, 0); + } + }); + + it('degrades to a single valid point instead of NaN, empty, or a throw', () => { + // A NaN here would reach setValueCurveAtTime (throws) or AudioParam.value + // (silently mutes the filter), so degenerate counts must stay finite. + for (const points of [1, 0, -1, -100]) { + const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points); + expect(curve.length).toBe(1); + expect(Number.isFinite(curve[0]!)).toBe(true); + expect(curve[0]!).toBeCloseTo(DOOR_CUTOFF_HZ, 3); + } + }); + + it('never emits a non-finite sample for any point count', () => { + for (const points of [-5, 0, 1, 2, 3, 32, 128]) { + const curve = cutoffCurve(FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ, points); + expect(curve.length).toBeGreaterThan(0); + for (const hz of curve) expect(Number.isFinite(hz)).toBe(true); + } + }); + + it('rises monotonically opening and falls monotonically closing', () => { + const up = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, 64); + for (let i = 1; i < up.length; i++) expect(up[i]!).toBeGreaterThan(up[i - 1]!); + + const down = cutoffCurve(FLOOR_CUTOFF_HZ, DOOR_CUTOFF_HZ, 64); + for (let i = 1; i < down.length; i++) expect(down[i]!).toBeLessThan(down[i - 1]!); + }); + + it('samples the same curve cutoffAt describes', () => { + const points = 16; + const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, points); + for (let i = 0; i < points; i++) { + const expected = cutoffAt(i / (points - 1), DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ); + // Float32 storage costs precision, so compare relatively. + expect(curve[i]! / expected).toBeCloseTo(1, 5); + } + }); + + it('keeps its midpoint well below the linear halfway mark', () => { + const curve = cutoffCurve(DOOR_CUTOFF_HZ, FLOOR_CUTOFF_HZ, 33); + expect(curve[16]!).toBeCloseTo(Math.sqrt(DOOR_CUTOFF_HZ * FLOOR_CUTOFF_HZ), 1); + expect(curve[16]!).toBeLessThan((DOOR_CUTOFF_HZ + FLOOR_CUTOFF_HZ) / 4); + }); +}); diff --git a/tests/scheduling.test.ts b/tests/scheduling.test.ts new file mode 100644 index 0000000..44b8f8f --- /dev/null +++ b/tests/scheduling.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; +import { + barOf, + beatInBar, + beatIntervalMs, + beatTimeMs, + dueBeats, + isDownbeat, + patternStep, + subdivisions, + type BeatGrid, +} from '../src/audio/scheduling'; + +const grid = (bpm: number, originMs = 0): BeatGrid => ({ bpm, originMs }); + +describe('beatIntervalMs', () => { + it('converts BPM to milliseconds per beat', () => { + expect(beatIntervalMs(120)).toBe(500); + expect(beatIntervalMs(128)).toBe(468.75); + }); +}); + +describe('beatTimeMs', () => { + it('places beat 0 at the origin and steps by one interval', () => { + const g = grid(120); + expect(beatTimeMs(g, 0)).toBe(0); + expect(beatTimeMs(g, 1)).toBe(500); + expect(beatTimeMs(g, 8)).toBe(4000); + }); + + it('offsets every beat by a non-zero origin', () => { + const g = grid(128, 1234.5); + expect(beatTimeMs(g, 0)).toBe(1234.5); + for (let i = 0; i < 16; i++) { + expect(beatTimeMs(g, i)).toBeCloseTo(1234.5 + i * 468.75, 6); + } + }); + + it('handles negative indices (beats before the origin)', () => { + expect(beatTimeMs(grid(120, 1000), -2)).toBe(0); + }); +}); + +describe('dueBeats', () => { + it('returns only beats inside the lookahead horizon, ascending from fromBeat', () => { + // 500ms grid, now 0, 1200ms horizon -> beats 0,1,2 (2 sounds at 1000) + const due = dueBeats(grid(120), 0, 0, 1200); + expect(due.map((b) => b.beatIndex)).toEqual([0, 1, 2]); + expect(due.map((b) => b.timeMs)).toEqual([0, 500, 1000]); + }); + + it('includes a beat landing exactly on the horizon', () => { + expect(dueBeats(grid(120), 0, 0, 1000).map((b) => b.beatIndex)).toEqual([0, 1, 2]); + }); + + it('returns nothing when the next beat is beyond the horizon', () => { + // beat 4 sounds at 2000; waking at 1600 with a 100ms lookahead is too early + expect(dueBeats(grid(120), 4, 1600, 100)).toEqual([]); + }); + + it('respects fromBeat rather than replaying earlier beats', () => { + const due = dueBeats(grid(120), 3, 0, 2500); + expect(due.map((b) => b.beatIndex)).toEqual([3, 4, 5]); + }); + + it('respects the grid origin', () => { + // origin 10s: nothing is due while audio time is still near zero + expect(dueBeats(grid(120, 10_000), 0, 0, 100)).toEqual([]); + expect(dueBeats(grid(120, 10_000), 0, 9_950, 100).map((b) => b.beatIndex)).toEqual([0]); + }); + + it('yields every beat exactly once, in order, across ragged wakeups', () => { + const g = grid(128, 40); + const lookahead = 100; + // Irregular timer wakeups: some shorter than the lookahead, some longer than a beat. + const wakes = [25, 31, 18, 40, 25, 7, 63, 25, 12, 90, 25, 33]; + const seen: number[] = []; + let cursor = 0; + let now = 0; + // Bounded by wake count, not by beats collected: a regression that stops + // returning beats must go red, not spin forever and hang `npm test`. + for (let w = 0; w < 2000; w++) { + now += wakes[w % wakes.length] ?? 25; + const due = dueBeats(g, cursor, now, lookahead); + for (const b of due) { + expect(b.timeMs).toBeCloseTo(beatTimeMs(g, b.beatIndex), 6); + expect(b.timeMs).toBeLessThanOrEqual(now + lookahead); + seen.push(b.beatIndex); + } + const last = due.at(-1); + if (last) cursor = last.beatIndex + 1; + } + // Every beat whose time fell inside the final horizon should have sounded. + const expected = Math.floor((now + lookahead - 40) / beatIntervalMs(128)) + 1; + expect(expected).toBeGreaterThan(100); // sanity: the walk really is long + expect(seen.length).toBe(expected); + // No gaps, no duplicates, strictly ascending from 0. + expect(seen).toEqual(seen.map((_, i) => i)); + }); + + it('never schedules a beat more than a lookahead ahead of the wake time', () => { + const g = grid(120); + let cursor = 0; + let scheduled = 0; + let lastNow = 0; + for (let now = 0; now < 20_000; now += 25) { + const due = dueBeats(g, cursor, now, 120); + for (const b of due) expect(b.timeMs).toBeLessThanOrEqual(now + 120); + scheduled += due.length; + const last = due.at(-1); + if (last) cursor = last.beatIndex + 1; + lastNow = now; + } + // Scheduling nothing would satisfy the bound above vacuously; silence is the + // failure mode this test exists to catch. + expect(scheduled).toBe(Math.floor((lastNow + 120) / beatIntervalMs(120)) + 1); + }); + + it('caps a suspended-tab backlog at MAX_BURST and drains it without loss', () => { + const g = grid(128); + const stalledNow = 5 * 60_000; // tab was hidden for five minutes + const lookahead = 100; + const expectedTotal = Math.floor((stalledNow + lookahead) / beatIntervalMs(128)) + 1; + expect(expectedTotal).toBeGreaterThan(600); // sanity: the burst really is huge + + const first = dueBeats(g, 0, stalledNow, lookahead); + expect(first.length).toBe(64); + expect(first.map((b) => b.beatIndex)).toEqual(first.map((_, i) => i)); + + // Subsequent wakes at the same audio time drain the rest, 64 at a time. + const seen = first.map((b) => b.beatIndex); + let cursor = 64; + for (let guard = 0; guard < 100; guard++) { + const due = dueBeats(g, cursor, stalledNow, lookahead); + if (due.length === 0) break; + expect(due.length).toBeLessThanOrEqual(64); + for (const b of due) seen.push(b.beatIndex); + cursor = due[due.length - 1]!.beatIndex + 1; + } + expect(seen).toEqual(seen.map((_, i) => i)); + expect(seen.length).toBe(expectedTotal); + }); +}); + +describe('subdivisions', () => { + it('returns n evenly spaced times starting on the beat', () => { + const g = grid(120, 250); + const subs = subdivisions(g, 2, 4); // beat 2 sounds at 1250 + expect(subs.length).toBe(4); + expect(subs[0]).toBe(beatTimeMs(g, 2)); + expect(subs).toEqual([1250, 1375, 1500, 1625]); + }); + + it('lands the next beat exactly one step past the last subdivision', () => { + const g = grid(128, 12); + const subs = subdivisions(g, 7, 16); + const step = beatIntervalMs(128) / 16; + expect(subs.length).toBe(16); + expect(subs[15]! + step).toBeCloseTo(beatTimeMs(g, 8), 6); + }); +}); + +describe('bar position (4/4)', () => { + it('maps beat indices to bar and position', () => { + expect(barOf(0)).toBe(0); + expect(beatInBar(0)).toBe(0); + expect(isDownbeat(0)).toBe(true); + + for (let i = 0; i < 32; i++) { + expect(barOf(i)).toBe(Math.floor(i / 4)); + expect(beatInBar(i)).toBe(i % 4); + expect(isDownbeat(i)).toBe(i % 4 === 0); + } + }); + + it('keeps beatInBar non-negative before the origin', () => { + expect(beatInBar(-1)).toBe(3); + expect(beatInBar(-4)).toBe(0); + expect(isDownbeat(-4)).toBe(true); + // barOf must floor, not truncate, or beat -1 lands in bar 0 alongside beat 3. + expect(barOf(-1)).toBe(-1); + expect(barOf(-4)).toBe(-1); + expect(barOf(-5)).toBe(-2); + }); +}); + +describe('patternStep', () => { + it('walks 0..31 across two bars then wraps', () => { + const walk: number[] = []; + for (let beat = 0; beat < 8; beat++) { + for (let s = 0; s < 4; s++) walk.push(patternStep(beat, s)); + } + expect(walk.length).toBe(32); + expect(walk).toEqual(walk.map((_, i) => i)); + expect(patternStep(8, 0)).toBe(0); + }); + + it('repeats with period 32 rather than drifting', () => { + for (let beat = 0; beat < 64; beat++) { + for (let s = 0; s < 4; s++) { + expect(patternStep(beat, s)).toBe(patternStep(beat + 8, s)); + expect(patternStep(beat, s)).toBe((beat * 4 + s) % 32); + } + } + }); + + it('honours a custom step count and negative indices', () => { + expect(patternStep(4, 0, 16)).toBe(0); + expect(patternStep(3, 2, 16)).toBe(14); + expect(patternStep(-1, 0)).toBe(28); + }); +}); diff --git a/tests/typo.test.ts b/tests/typo.test.ts new file mode 100644 index 0000000..388cafc --- /dev/null +++ b/tests/typo.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; +import { SeededRNG } from '../src/core/SeededRNG'; +import { drunkify, typedLength, wobbleAt, type RngLike } from '../src/ui/typo'; + +/** Fresh stream every call — drunkify consumes draws, so tests must not share state. */ +function stream(seed: number, name = 'typo'): RngLike { + return new SeededRNG(seed).stream(name); +} + +const LINE = + "Yeah nah mate the line's round the corner, hop in and grab a schooner before last drinks at half two."; + +// Long enough that two different seeds colliding on an identical mangle is not plausible. +const CORPUS = Array.from({ length: 6 }, () => LINE).join(' '); + +function levenshtein(a: string, b: string): number { + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const row = [i]; + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + row.push(Math.min(row[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost)); + } + prev = row; + } + return prev[b.length]!; +} + +const nonLetters = (s: string): string => s.replace(/[a-zA-Z]/g, ''); + +/** + * Matches exactly those strings that keep `input`'s non-letter skeleton verbatim + * while every letter run has become zero or more *letters*. Comparing against this + * — rather than stripping non-letters first, which makes the assertion vacuous — + * is what catches a letter position emitting something that is not a letter. + */ +function letterShape(input: string): RegExp { + const MARK = '\u0000'; // cannot occur in dialogue text, so it is a safe placeholder + const src = input + .replace(/[a-zA-Z]+/g, MARK) + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .split(MARK) + .join('[a-zA-Z]*'); + return new RegExp(`^${src}$`); +} + +describe('drunkify', () => { + it('is byte-identical for the same text, drunkenness and seed', () => { + const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) }); + const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) }); + expect(a).toBe(b); + expect(a).not.toBe(CORPUS); // sanity: 0.8 actually mangles something + }); + + it('diverges between seeds', () => { + const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1) }); + const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(2) }); + expect(a).not.toBe(b); + }); + + it('is stream-scoped, not seed-scoped', () => { + const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:dialogue') }); + const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:heckle') }); + expect(a).not.toBe(b); + }); + + it('leaves text untouched at drunkenness 0, without consuming draws', () => { + const rng = stream(99); + expect(drunkify(LINE, { drunkenness: 0, rng })).toBe(LINE); + expect(rng.next()).toBe(stream(99).next()); + }); + + it('leaves text untouched at or below the 0.25 sober floor', () => { + for (const d of [0.05, 0.1, 0.2, 0.249, 0.25]) { + expect(drunkify(CORPUS, { drunkenness: d, rng: stream(5) })).toBe(CORPUS); + } + // Just above the floor the odds per letter are tiny, so check across seeds + // that *something* slips — the threshold is a ramp, not a hard cut. + const seeds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const mangled = seeds.filter((s) => drunkify(CORPUS, { drunkenness: 0.3, rng: stream(s) }) !== CORPUS); + expect(mangled.length).toBeGreaterThan(0); + }); + + it('clamps drunkenness into 0..1', () => { + expect(drunkify(CORPUS, { drunkenness: 4, rng: stream(42) })).toBe( + drunkify(CORPUS, { drunkenness: 1, rng: stream(42) }), + ); + expect(drunkify(CORPUS, { drunkenness: -3, rng: stream(42) })).toBe(CORPUS); + }); + + it('treats a non-finite drunkenness as sober, without consuming draws', () => { + for (const d of [NaN, Infinity, -Infinity]) { + const rng = stream(77); + expect(drunkify(CORPUS, { drunkenness: d, rng })).toBe(CORPUS); + expect(rng.next()).toBe(stream(77).next()); + } + }); + + it('never substitutes or drops non-letters, even at maximum', () => { + const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n'; + for (const seed of [11, 12, 13, 14, 15]) { + const out = drunkify(punctuated, { drunkenness: 1, rng: stream(seed) }); + expect(nonLetters(out)).toBe(nonLetters(punctuated)); + } + }); + + it('only ever emits letters where letters were', () => { + const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n'; + for (const source of [CORPUS, punctuated]) { + const shape = letterShape(source); + for (const seed of [808, 809, 810]) { + expect(drunkify(source, { drunkenness: 1, rng: stream(seed) })).toMatch(shape); + } + } + }); + + it('mangles substantially more the drunker the speaker is', () => { + // Statistical, so average over several seeds and assert a loose ratio. + let tipsy = 0; + let maggot = 0; + for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) { + tipsy += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 0.4, rng: stream(seed) })); + maggot += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 1, rng: stream(seed) })); + } + expect(tipsy).toBeGreaterThan(0); + expect(maggot).toBeGreaterThan(tipsy * 2); + }); +}); + +describe('typedLength', () => { + it('reveals nothing at zero elapsed', () => { + expect(typedLength(LINE, 0, 28)).toBe(0); + expect(typedLength(LINE, 0, 28, 1)).toBe(0); + }); + + it('reaches but never exceeds the full length', () => { + expect(typedLength(LINE, LINE.length * 28, 28)).toBe(LINE.length); + expect(typedLength(LINE, 10_000_000, 28, 1)).toBe(LINE.length); + expect(typedLength('', 10_000, 28)).toBe(0); + }); + + it('is monotonic in elapsed time', () => { + let prev = -1; + for (let ms = 0; ms <= 4000; ms += 17) { + const n = typedLength(LINE, ms, 28, 0.7); + expect(n).toBeGreaterThanOrEqual(prev); + prev = n; + } + }); + + it('makes drunk speakers type slower', () => { + const sober = typedLength(LINE, 900, 28, 0); + const drunk = typedLength(LINE, 900, 28, 1); + expect(drunk).toBeLessThan(sober); + expect(typedLength(LINE, 900, 28, 0.5)).toBeLessThan(sober); + }); + + it('clamps drunkenness like drunkify does', () => { + expect(typedLength(LINE, 900, 28, 5)).toBe(typedLength(LINE, 900, 28, 1)); + expect(typedLength(LINE, 900, 28, -5)).toBe(typedLength(LINE, 900, 28, 0)); + }); + + it('short-circuits to full length when charMs is non-positive', () => { + expect(typedLength(LINE, 0, 0)).toBe(LINE.length); + expect(typedLength(LINE, 0, -5, 1)).toBe(LINE.length); + }); + + // DialogueBox accumulates `elapsed += delta`, so a NaN that escaped here would + // stall the reveal for the rest of the night rather than for one frame. + it('treats a non-finite elapsed or drunkenness as zero', () => { + expect(typedLength(LINE, NaN, 28)).toBe(0); + expect(typedLength(LINE, NaN, 28, 1)).toBe(0); + expect(typedLength(LINE, 900, 28, NaN)).toBe(typedLength(LINE, 900, 28, 0)); + }); +}); + +describe('wobbleAt', () => { + it('is flat when sober', () => { + for (let i = 0; i < 20; i++) expect(wobbleAt(i, 0, i * 0.3)).toBe(0); + expect(wobbleAt(3, -1, 1.2)).toBe(0); + }); + + it('stays within the 1.6px amplitude at maximum', () => { + for (let i = 0; i < 200; i++) { + for (const phase of [0, 0.7, 2.5, 6.1]) { + expect(Math.abs(wobbleAt(i, 1, phase))).toBeLessThanOrEqual(1.6); + expect(Math.abs(wobbleAt(i, 4, phase))).toBeLessThanOrEqual(1.6); // clamped + } + } + }); + + it('scales with drunkenness', () => { + // Index/phase chosen so sin() is well away from a zero crossing. + const at = (d: number): number => Math.abs(wobbleAt(0, d, Math.PI / 2)); + expect(at(0.5)).toBeGreaterThan(at(0.25)); + expect(at(1)).toBeGreaterThan(at(0.5)); + }); + + // Same reasoning as typedLength: phase is accumulated, and a NaN reaching + // text.y makes Phaser drop the glyph silently instead of erroring. + it('treats a non-finite phase or drunkenness as zero', () => { + expect(wobbleAt(0, 1, NaN)).toBe(0); + expect(wobbleAt(5, 1, Infinity)).toBe(0); + expect(wobbleAt(5, NaN, 1.2)).toBe(0); + }); + + it('is deterministic for the same index, drunkenness and phase', () => { + expect(wobbleAt(7, 0.6, 1.25)).toBe(wobbleAt(7, 0.6, 1.25)); + expect(wobbleAt(8, 0.6, 1.25)).not.toBe(wobbleAt(7, 0.6, 1.25)); + }); +});