Merge branch 'lane/juice'

# Conflicts:
#	LANEHANDOVER.md
#	src/main.ts
This commit is contained in:
type-two 2026-07-19 19:15:55 +10:00
commit ca8fd75aac
18 changed files with 3971 additions and 4 deletions

View File

@ -408,3 +408,72 @@ UV shows the stamp glow and marches an unstamped patron out with a Kayden radio
- Fight-brewing infraction (design §3.2) is specced but NOT built — the only v0.2
floor mechanic still missing.
- Replace `HudStub` with LANE-JUICE's real meter HUD.
### SESSION — LANE-JUICE — 2026-07-19 18:55
**Branch/commits:** lane/juice @ c262ac8..2eb3c94
**Done:**
- `audio/TechnoEngine.ts` — synthesized 128 BPM track (kick / off-beat hats /
2-bar 16th bassline / 8-bar pad), look-ahead scheduler (25ms timer, 100ms
horizon) as the beat authority. Emits `beat:tick {beatIndex, audioTimeMs}`
with the **scheduled** time, plus `beat:bar` on downbeats. Drop-in replacement
for `core/StubBeatClock`. Music → lowpass → master; SFX bypass the filter.
- `audio/Sfx.ts` — 10 procedural one-shots + `startRain`/`stopRain`. One shared
noise buffer; voices disconnect on `ended`.
- `audio/scheduling.ts` + `audio/filterCurve.ts` — pure, node-testable beat grid
and cutoff-ramp math (the WebAudio classes are thin wrappers over these).
- `ui/``Stamp`, `Phone`, `DialogueBox`, `Clicker`, `MeterHud`, plus
`style.ts` (chunky widget kit) and `typo.ts` (deterministic drunk typos).
- `ui/JuiceDemoScene.ts` — exercises every widget and SFX; log-mapped cutoff
slider, DOOR/FLOOR toggle routed through the bus, beat pad that flashes when
`ctx.currentTime` reaches the scheduled time (not on event receipt).
**How to see it:** `npm run dev` → http://localhost:5199/#juice (or press **J**;
**P** returns to the parade). Click once to unlock audio, then: drag MUFFLE to
sweep the filter by hand, hit STEP INSIDE for the door→floor sweep, mash the
SOUNDS column, stamp someone with NOT TONIGHT.
**Tests:** 111 passing / 0 failing (was 49 at Phase 0; +62 this lane).
**Verified by ear:** NOTHING. No audio output available in this environment.
Everything below was logic-checked in a real browser via the exposed engine:
- door→floor sweep ramps 250Hz→18kHz over 600ms, monotonic, exponential
(t=0.5 lands ~2.1kHz vs the ~9.1kHz a linear ramp gives).
- under a genuinely hidden/throttled tab: 52 consecutive beats, **zero**
scheduled in the past, 7598ms lead, max 1 beat scheduled per timer wake.
**John/Fable still need to ear-check:** does the kick read as a kick, is the
bassline musical, is `typeTick` quiet enough at the real mix level (it fires per
character), and does the sweep *feel* like a door rather than a filter.
**Broke / known-wonky:**
- `src/main.ts` is edited (2 lines + a ~12-line demo route). **Outside LANE-JUICE
ownership** — see request 2 below. Without it the demo scene is unreachable
and Vite tree-shakes it out entirely.
- `TechnoEngine` has no test file — it needs a real `AudioContext` and the suite
runs in node. Its scheduling/filter math is tested via the pure modules, but
the `resync` arithmetic is only reachable through the class. Extracting
`resyncBeat(grid, nextBeat, nowMs)` into `scheduling.ts` would close that gap.
- `Phone` thread truncates an over-tall newest message rather than scrolling.
- `Stamp` decals are caller-owned now; the demo caps them at 5. The door scene
must destroy them (or pass `parent`) or they accumulate over a night.
**Contract change requests:**
1. **Add `door:phoneTheatre: { durationMs: number }` to `EventMap`.** Emitted by
`ui/Phone` when the player closes the phone, carrying how long they stared at
it while a queue waited — the door scene rewards it with hype. It is a door
event, not an audio one, so it is outside my namespace. **I did not edit
`data/types.ts`.** `ui/juiceEvents.ts` declaration-merges it into `EventMap`
instead: fully typed, zero edits to the frozen file, and folding it into the
real contract later is a copy-paste. Same file also adds `audio:unlocked` and
`beat:bar`, which ARE mine per LANE_JUICE.md but are declared together so the
whole juice surface reads in one place.
2. **Sign-off requested on the `src/main.ts` edit** (scene registration + J/#juice
route, marked `// TODO(integration)`). Happy to revert it if you would rather
wire the demo from the Phase 2 shell yourself.
**Questions for reviewer:**
1. `TechnoEngine.unlock()` anchors beat 0 at `currentTime + 60ms` so beat 0 is as
honest as every other beat. If a consumer assumes beat 0 lands exactly at
`audio:unlocked.atMs`, that is a 60ms discrepancy — is that acceptable, or
should `atMs` carry the anchored origin instead?
2. `MeterHud` counts heat strikes from `heat:strike` events it receives. On a
mid-run scene swap it would restart from 0. Should it take an initial count in
its options, or read `NightState.heatStrikes.length` at construction?
**Next session should:**
- LANE-DOOR: swap the local stamp for `ui/Stamp` (`// TODO(juice)`), and take
`MeterHud` in place of `HudStub`.
- Someone with speakers should ear-check the mix and tell me what to retune.
- Consider extracting `resyncBeat` into `scheduling.ts` so the throttled-tab
resync gets direct test coverage.

455
src/audio/Sfx.ts Normal file
View File

@ -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<AudioScheduledSourceNode>();
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;
}

460
src/audio/TechnoEngine.ts Normal file
View File

@ -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<AudioScheduledSourceNode, AudioNode[]>();
private timer: ReturnType<typeof setInterval> | null = null;
private nextBeat = 0;
private loc: AudioLocation = 'door';
private override: number | null = null;
private unlockedFlag = false;
private unlocking: Promise<void> | 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<void> {
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));
}
}

66
src/audio/filterCurve.ts Normal file
View File

@ -0,0 +1,66 @@
// Pure math for the location filter — the game's signature audio trick.
//
// Music runs through one master lowpass. At the door you hear the kick through a
// brick wall; step inside and the filter opens. The sweep must feel like a door
// swinging, not a fader move, so it ramps exponentially in frequency (which is
// how ears hear pitch) rather than linearly in Hz.
export type AudioLocation = 'door' | 'floor';
/** Muffled-through-the-wall. Low enough that hats vanish and only kick survives. */
export const DOOR_CUTOFF_HZ = 250;
/** Effectively open — above this the filter is inaudible. */
export const FLOOR_CUTOFF_HZ = 18_000;
/** Music dips slightly outside; inside it is full. */
export const DOOR_GAIN = 0.62;
export const FLOOR_GAIN = 1;
/** Door opening is a fast reveal; door closing behind you is slower and sadder. */
export const OPEN_SWEEP_MS = 600;
export const CLOSE_SWEEP_MS = 900;
export const cutoffFor = (location: AudioLocation): number =>
location === 'floor' ? FLOOR_CUTOFF_HZ : DOOR_CUTOFF_HZ;
export const gainFor = (location: AudioLocation): number =>
location === 'floor' ? FLOOR_GAIN : DOOR_GAIN;
/** Opening (door -> floor) is the fast one. */
export const sweepMsFor = (to: AudioLocation): number =>
to === 'floor' ? OPEN_SWEEP_MS : CLOSE_SWEEP_MS;
/**
* Cutoff at normalised progress t (0..1) of a sweep, interpolated exponentially.
* t is clamped, so callers can hand it raw elapsed/duration.
*
* Exponential in frequency == linear in perceived pitch: at t=0.5 of a
* 250Hz->18kHz sweep you are at ~2.1kHz, not the ~9kHz a linear ramp would give.
* That late-blooming brightness is what sells "the door just opened".
*/
export function cutoffAt(t: number, fromHz: number, toHz: number): number {
const clamped = Math.max(0, Math.min(1, t));
return fromHz * Math.pow(toHz / fromHz, clamped);
}
/** Master gain at progress t — linear is fine, the ear tracks the filter here. */
export function gainAt(t: number, from: number, to: number): number {
const clamped = Math.max(0, Math.min(1, t));
return from + (to - from) * clamped;
}
/**
* Sampled ramp points for `setValueCurveAtTime`. WebAudio's
* exponentialRampToValueAtTime would do the job, but sampling lets us assert the
* curve's shape in tests and keeps door/floor behaviour identical everywhere.
*/
export function cutoffCurve(fromHz: number, toHz: number, points = 32): Float32Array {
// 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;
}

69
src/audio/scheduling.ts Normal file
View File

@ -0,0 +1,69 @@
// Pure look-ahead scheduling math for TechnoEngine.
//
// The engine runs a 25ms timer; on each wake it asks this module which beats fall
// inside the next `lookaheadMs` of audio time and schedules oscillators for them.
// Keeping the arithmetic here means the beat grid is unit-testable without an
// AudioContext (tests run in node — there is no WebAudio there).
export interface BeatGrid {
bpm: number;
/** AudioContext time (ms) at which beat 0 sounds. */
originMs: number;
}
export interface ScheduledBeat {
beatIndex: number;
/** Absolute AudioContext time (ms) the beat sounds at. */
timeMs: number;
}
export const beatIntervalMs = (bpm: number): number => 60_000 / bpm;
/** Absolute audio time of a given beat index. */
export const beatTimeMs = (grid: BeatGrid, beatIndex: number): number =>
grid.originMs + beatIndex * beatIntervalMs(grid.bpm);
/**
* Every beat with index >= fromBeat whose time falls at or before
* `nowMs + lookaheadMs`. Returned in ascending order; empty when nothing is due.
*
* The engine advances its own cursor past the last returned index, so a beat is
* never scheduled twice however ragged the timer wakeups are.
*/
export function dueBeats(
grid: BeatGrid,
fromBeat: number,
nowMs: number,
lookaheadMs: number,
): ScheduledBeat[] {
const horizon = nowMs + lookaheadMs;
const out: ScheduledBeat[] = [];
// Guard against a pathological horizon (tab suspended for minutes) producing a
// scheduling storm — we cap the burst and let the cursor catch up next wake.
const MAX_BURST = 64;
for (let i = fromBeat; out.length < MAX_BURST; i++) {
const timeMs = beatTimeMs(grid, i);
if (timeMs > horizon) break;
out.push({ beatIndex: i, timeMs });
}
return out;
}
/** 16th-note subdivision times within a beat, for hats and the bassline. */
export function subdivisions(grid: BeatGrid, beatIndex: number, n: number): number[] {
const step = beatIntervalMs(grid.bpm) / n;
const base = beatTimeMs(grid, beatIndex);
return Array.from({ length: n }, (_, i) => base + i * step);
}
/** Bar/beat position of a beat index in 4/4. */
export const barOf = (beatIndex: number): number => Math.floor(beatIndex / 4);
export const beatInBar = (beatIndex: number): number => ((beatIndex % 4) + 4) % 4;
export const isDownbeat = (beatIndex: number): boolean => beatInBar(beatIndex) === 0;
/**
* Where a 16th-note step sits in the 2-bar (32-step) bassline pattern.
* Exposed so tests can assert the pattern wraps rather than drifting.
*/
export const patternStep = (beatIndex: number, sixteenth: number, steps = 32): number =>
(((beatIndex * 4 + sixteenth) % steps) + steps) % steps;

View File

@ -5,11 +5,11 @@ import { NightScene } from './scenes/door/NightScene';
import { DoorScene } from './scenes/door/DoorScene';
import { NightSummaryScene } from './scenes/door/NightSummaryScene';
import { FloorDemoScene } from './scenes/floor/FloorDemoScene';
import { JuiceDemoScene } from './ui/JuiceDemoScene';
// TODO(integration): Phase 2 owns scene routing (menu -> roster -> night). For
// v0.1 the game IS the Door night, so it boots straight into it. Boot/Parade
// stay registered so the Phase-0 demo survives:
// game.scene.start('Parade') from the console.
// v0.1 the game IS the Door night, so it boots straight into it. The demo scenes
// stay reachable via dev routes below.
const game = new Phaser.Game({
type: Phaser.AUTO,
width: 640,
@ -21,8 +21,42 @@ const game = new Phaser.Game({
autoCenter: Phaser.Scale.CENTER_BOTH,
zoom: Phaser.Scale.MAX_ZOOM,
},
scene: [NightScene, DoorScene, NightSummaryScene, BootScene, ParadeScene, FloorDemoScene],
scene: [NightScene, DoorScene, NightSummaryScene, BootScene, ParadeScene, FloorDemoScene, JuiceDemoScene],
});
// debug handle (harmless in prod; used by dev tooling)
(window as unknown as { game: Phaser.Game }).game = game;
// TODO(integration): dev routing until Phase 2's shell exists.
// Keys: N night (the game) · P parade · F floor demo · J juice demo.
// URL hashes #night/#parade/#floor/#juice pick the boot scene.
// NightScene owns DoorScene/NightSummaryScene as children; stopping Night stops them.
const ROUTES = { night: 'Night', parade: 'Parade', floor: 'FloorDemo', juice: 'JuiceDemo' } as const;
type RouteKey = keyof typeof ROUTES;
const showScene = (target: (typeof ROUTES)[RouteKey]): void => {
for (const key of ['Night', 'Door', 'NightSummary', 'Parade', 'FloorDemo', 'JuiceDemo']) {
if (key !== target && game.scene.isActive(key)) game.scene.stop(key);
}
if (!game.scene.isActive(target)) game.scene.start(target);
};
// NightScene is scene[0] and auto-starts. Hash routing must wait until it is
// actually up before swapping, or both scenes end up running on top of each other.
const hash = window.location.hash.slice(1) as RouteKey;
if (hash in ROUTES && ROUTES[hash] !== 'Night') {
const route = (): void => {
if (!game.scene.isActive('Night')) return;
game.events.off(Phaser.Core.Events.POST_STEP, route);
showScene(ROUTES[hash]);
};
game.events.on(Phaser.Core.Events.POST_STEP, route);
}
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (k === 'n') showScene('Night');
else if (k === 'p') showScene('Parade');
else if (k === 'f') showScene('FloorDemo');
else if (k === 'j') showScene('JuiceDemo');
});

178
src/ui/Clicker.ts Normal file
View File

@ -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;
}
}

288
src/ui/DialogueBox.ts Normal file
View File

@ -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;
}
}

564
src/ui/JuiceDemoScene.ts Normal file
View File

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

250
src/ui/MeterHud.ts Normal file
View File

@ -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();
}
}

396
src/ui/Phone.ts Normal file
View File

@ -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 };
}
}

268
src/ui/Stamp.ts Normal file
View File

@ -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 };
}

23
src/ui/juiceEvents.ts Normal file
View File

@ -0,0 +1,23 @@
// LANE-JUICE event extensions.
//
// TODO(contract): `door:phoneTheatre` lives outside the audio domain, so it needs
// reviewer sign-off before it moves into data/types.ts. Until then we merge it
// into EventMap by declaration merging rather than editing the frozen contract
// file — same typing guarantees, zero edits to LANE-0 territory. See the CONTRACT
// CHANGE REQUEST in LANEHANDOVER.md.
//
// `audio:*` / `beat:*` additions are ours to extend freely (LANE_JUICE.md), but
// they are declared here too so the whole juice surface reads in one place.
declare module '../data/types' {
interface EventMap {
/** Player fiddled with their phone while a patron waits — pure theatre. */
'door:phoneTheatre': { durationMs: number };
/** AudioContext resumed after the first real user gesture. */
'audio:unlocked': { atMs: number };
/** Bar line boundary (every 4th beat) — for anything that wants downbeats. */
'beat:bar': { barIndex: number; beatIndex: number; audioTimeMs: number };
}
}
export {};

114
src/ui/style.ts Normal file
View File

@ -0,0 +1,114 @@
import Phaser from 'phaser';
import { PALETTE } from '../data/outfits';
// Shared look for every juice widget. Chunky, high contrast, slightly grubby —
// this club has not been deep-cleaned since the Olympics. Colours lean on the
// doll PALETTE so UI and patrons live in the same world.
export const UI = {
ink: 0x0b0b12,
panel: 0x1a1a24,
panelLip: 0x2c2c3a,
grime: 0x24242e,
paper: PALETTE.paper ?? 0xdad4c4,
neonPink: 0xd03470,
neonGreen: 0x9fe8a0,
kebabAmber: 0xd8a020,
danger: PALETTE.red ?? 0xc03434,
ok: PALETTE.green ?? 0x2e7d46,
dim: 0x556,
} as const;
export const hex = (c: number): string => `#${c.toString(16).padStart(6, '0')}`;
export const font = (size: number, colour: number): Phaser.Types.GameObjects.Text.TextStyle => ({
fontFamily: 'monospace',
fontSize: `${size}px`,
color: hex(colour),
});
/**
* A grubby recessed panel: dark fill, lit top lip, dark bottom shadow. Returns a
* container positioned at (x, y) with the panel centred on its origin, so
* callers can add children in local coordinates.
*/
export function panel(
scene: Phaser.Scene,
x: number,
y: number,
w: number,
h: number,
fill: number = UI.panel,
): Phaser.GameObjects.Container {
const c = scene.add.container(x, y);
c.add(scene.add.rectangle(0, 0, w, h, fill).setStrokeStyle(1, UI.ink));
c.add(scene.add.rectangle(0, -h / 2 + 1, w - 2, 1, UI.panelLip, 0.5));
c.add(scene.add.rectangle(0, h / 2 - 1, w - 2, 1, UI.ink, 0.6));
return c;
}
export interface ChunkyButtonOptions {
w?: number;
h?: number;
fill?: number;
textColour?: number;
fontSize?: number;
onClick: () => void;
}
/**
* Chunky pixel button with a real press: it drops 1px and darkens on pointerdown
* and springs back on release. The 1px travel is most of why it feels physical.
*/
export function chunkyButton(
scene: Phaser.Scene,
x: number,
y: number,
label: string,
opts: ChunkyButtonOptions,
): Phaser.GameObjects.Container {
const w = opts.w ?? 56;
const h = opts.h ?? 16;
const fill = opts.fill ?? UI.panelLip;
const c = scene.add.container(x, y);
const face = scene.add.rectangle(0, 0, w, h, fill).setStrokeStyle(1, UI.ink);
const shadow = scene.add.rectangle(0, h / 2, w, 2, UI.ink, 0.8);
const text = scene.add
.text(0, 0, label, font(opts.fontSize ?? 8, opts.textColour ?? UI.paper))
.setOrigin(0.5);
c.add([shadow, face, text]);
face.setInteractive({ useHandCursor: true });
const press = (down: boolean): void => {
face.y = down ? 1 : 0;
text.y = down ? 1 : 0;
shadow.setAlpha(down ? 0.2 : 0.8);
face.setFillStyle(down ? Phaser.Display.Color.ValueToColor(fill).darken(20).color : fill);
};
// 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);
});
face.on('pointerup', () => {
const fire = armed;
armed = false;
press(false);
if (fire) opts.onClick();
});
return c;
}
/** Short, sharp camera shake. Used by the stamp and anything else with weight. */
export function shake(scene: Phaser.Scene, px = 4, ms = 120): void {
scene.cameras.main.shake(ms, px / 1000, true);
}

110
src/ui/typo.ts Normal file
View File

@ -0,0 +1,110 @@
// Drunk-typo text renderer for DialogueBox.
//
// Pure and deterministic: same (text, drunkenness, seed) always yields the same
// mangled string, so a patron's slurred answer is reproducible from the run seed
// like everything else. No Math.random — callers pass a SeededRNG stream.
export interface RngLike {
/** 0..1, like SeededRNG streams. */
next(): number;
}
/** Adjacent-key swaps — a drunk thumb misses sideways, not randomly. */
const NEIGHBOURS: Record<string, string> = {
a: 's', b: 'v', c: 'x', d: 'f', e: 'w', f: 'g', g: 'h', h: 'j', i: 'o',
j: 'k', k: 'l', l: 'k', m: 'n', n: 'm', o: 'p', p: 'o', q: 'w', r: 't',
s: 'a', t: 'r', u: 'i', v: 'b', w: 'e', x: 'z', y: 'u', z: 'x',
};
/** 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;
rng: RngLike;
}
/**
* Mangle a line in proportion to how gone the speaker is.
*
* Four escalating effects, each gated on its own probability so a tipsy speaker
* gets the occasional doubled letter and a maggot one gets all of it at once:
* - adjacent-key substitution
* - doubled letters
* - dropped letters
* - stretched vowels ("yeahhh")
*/
export function drunkify(text: string, { drunkenness, rng }: TypoOptions): string {
const d = clamp01(drunkenness);
if (d <= 0) return text;
// Below ~0.25 people type fine. Above that it ramps quickly.
const intensity = Math.max(0, (d - 0.25) / 0.75);
if (intensity <= 0) return text;
const pSub = intensity * 0.14;
const pDouble = intensity * 0.09;
const pDrop = intensity * 0.07;
const pStretch = intensity * 0.12;
let out = '';
for (const ch of text) {
const lower = ch.toLowerCase();
const isLetter = lower >= 'a' && lower <= 'z';
if (!isLetter) {
out += ch;
continue;
}
if (rng.next() < pDrop) continue;
let emitted = ch;
if (rng.next() < pSub) {
const swap = NEIGHBOURS[lower];
if (swap !== undefined) emitted = ch === lower ? swap : swap.toUpperCase();
}
out += emitted;
if (VOWELS.includes(lower) && rng.next() < pStretch) {
const extra = 1 + Math.floor(rng.next() * 3);
out += emitted.repeat(extra);
} else if (rng.next() < pDouble) {
out += emitted;
}
}
return out;
}
/**
* How many characters of `text` are visible after `elapsedMs` of typing.
* Drunk speakers type slower and in lurches the stagger is deterministic in
* the character index so it does not shimmer between frames.
*/
export function typedLength(text: string, elapsedMs: number, charMs: number, drunkenness = 0): number {
if (charMs <= 0) return text.length;
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 = clamp01(drunkenness) * 1.6;
if (amp <= 0) return 0;
const y = Math.sin(phase + index * 0.9) * amp;
return Number.isNaN(y) ? 0 : y;
}

200
tests/filterCurve.test.ts Normal file
View File

@ -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);
});
});

212
tests/scheduling.test.ts Normal file
View File

@ -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);
});
});

211
tests/typo.test.ts Normal file
View File

@ -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));
});
});