LANE-JUICE: audio engine, SFX set, five UI widgets, JuiceDemoScene
Audio (src/audio/), raw WebAudio, no samples or libraries: - TechnoEngine: 128 BPM four-on-the-floor kick, off-beat hats, 2-bar 16th bassline, 8-bar pad. Look-ahead scheduler (25ms timer, 100ms horizon) is the beat authority — emits beat:tick with the SCHEDULED audio time, so consumers can compare honestly. Replaces core/StubBeatClock at integration; same event. Stale beats are resynced to the next downbeat rather than caught up: a throttled/hidden tab otherwise dumps a 64-beat backlog onto one sample. Music runs through the location lowpass; SFX bypass it and stay crisp. - Sfx: 10 procedural one-shots + rain bed, one shared noise buffer, voices self-disconnect on ended so a six-hour night doesn't accumulate nodes. UI (src/ui/), reusable Phaser containers, no door/floor imports: - Stamp (the signature interaction), Phone (Dazza thread + phoneTheatre), DialogueBox (typed-out, drunk-typo render), Clicker (digit roll), MeterHud (neon vibe / crowd-temp aggro / hype badge / 3 heat slots). - JuiceDemoScene exercises every widget and SFX, with a log-mapped cutoff slider and a DOOR/FLOOR toggle that routes through the bus. Verified in-browser: the door->floor sweep ramps 250Hz->18kHz over 600ms, monotonic and exponential (halfway lands ~2.1kHz, not the ~9kHz a linear ramp would give) — that late bloom is what sells the door opening. Under a real hidden tab, 52 consecutive beats scheduled with zero in the past, 75-98ms lead, max one beat per wake. main.ts touched to route J / #juice to the demo — outside lane ownership, flagged for sign-off in LANEHANDOVER.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c262ac8261
commit
2eb3c94ccd
455
src/audio/Sfx.ts
Normal file
455
src/audio/Sfx.ts
Normal 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
460
src/audio/TechnoEngine.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -55,7 +55,12 @@ export function gainAt(t: number, from: number, to: number): number {
|
||||
* curve's shape in tests and keeps door/floor behaviour identical everywhere.
|
||||
*/
|
||||
export function cutoffCurve(fromHz: number, toHz: number, points = 32): Float32Array {
|
||||
const out = new Float32Array(points);
|
||||
for (let i = 0; i < points; i++) out[i] = cutoffAt(i / (points - 1), fromHz, toHz);
|
||||
// A degenerate count must still yield a usable curve: a NaN reaching
|
||||
// setValueCurveAtTime throws, and a NaN assigned to AudioParam.value kills the
|
||||
// filter silently, which would read as "the music stopped" with nothing logged.
|
||||
const n = Number.isFinite(points) ? Math.max(1, Math.floor(points)) : 1;
|
||||
const span = Math.max(1, n - 1);
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = cutoffAt(i / span, fromHz, toHz);
|
||||
return out;
|
||||
}
|
||||
|
||||
32
src/main.ts
32
src/main.ts
@ -1,6 +1,7 @@
|
||||
import Phaser from 'phaser';
|
||||
import { BootScene } from './scenes/shared/BootScene';
|
||||
import { ParadeScene } from './scenes/shared/ParadeScene';
|
||||
import { JuiceDemoScene } from './ui/JuiceDemoScene';
|
||||
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
@ -13,8 +14,37 @@ const game = new Phaser.Game({
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||
zoom: Phaser.Scale.MAX_ZOOM,
|
||||
},
|
||||
scene: [BootScene, ParadeScene],
|
||||
scene: [BootScene, ParadeScene, JuiceDemoScene],
|
||||
});
|
||||
|
||||
// debug handle (harmless in prod; used by dev tooling)
|
||||
(window as unknown as { game: Phaser.Game }).game = game;
|
||||
|
||||
// TODO(integration): LANE-JUICE demo route — J for the juice demo, P back to the
|
||||
// parade, or load with #juice. There is no menu yet; Phase 2's shell replaces this
|
||||
// whole block. Touching main.ts is outside LANE-JUICE's owned dirs (BUILD_PLAN §2)
|
||||
// and is flagged for sign-off in LANEHANDOVER.md — without it the demo scene is
|
||||
// unreachable dead code.
|
||||
const showScene = (key: 'Parade' | 'JuiceDemo'): void => {
|
||||
const other = key === 'Parade' ? 'JuiceDemo' : 'Parade';
|
||||
game.scene.stop(other);
|
||||
if (!game.scene.isActive(key)) game.scene.start(key);
|
||||
};
|
||||
|
||||
// BootScene starts Parade itself, and does it after READY fires — so routing on
|
||||
// READY stops a scene that is not up yet and we end up with both running on top
|
||||
// of each other. Wait for Parade to actually exist, then swap.
|
||||
if (window.location.hash === '#juice') {
|
||||
const route = (): void => {
|
||||
if (!game.scene.isActive('Parade')) return;
|
||||
game.events.off(Phaser.Core.Events.POST_STEP, route);
|
||||
showScene('JuiceDemo');
|
||||
};
|
||||
game.events.on(Phaser.Core.Events.POST_STEP, route);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === 'j') showScene('JuiceDemo');
|
||||
else if (k === 'p') showScene('Parade');
|
||||
});
|
||||
|
||||
178
src/ui/Clicker.ts
Normal file
178
src/ui/Clicker.ts
Normal 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
288
src/ui/DialogueBox.ts
Normal 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
564
src/ui/JuiceDemoScene.ts
Normal 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
250
src/ui/MeterHud.ts
Normal 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
396
src/ui/Phone.ts
Normal 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
268
src/ui/Stamp.ts
Normal 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 };
|
||||
}
|
||||
@ -85,11 +85,25 @@ export function chunkyButton(
|
||||
shadow.setAlpha(down ? 0.2 : 0.8);
|
||||
face.setFillStyle(down ? Phaser.Display.Color.ValueToColor(fill).darken(20).color : fill);
|
||||
};
|
||||
face.on('pointerdown', () => press(true));
|
||||
face.on('pointerout', () => press(false));
|
||||
face.on('pointerup', () => {
|
||||
// Phaser fires 'pointerup' on any release over the object, even one whose
|
||||
// 'pointerdown' landed elsewhere. Arm on our own down and disarm the moment the
|
||||
// pointer leaves, so a stray release can't phantom-click (clicker tally is load-bearing).
|
||||
let armed = false;
|
||||
face.on('pointerdown', () => {
|
||||
armed = true;
|
||||
press(true);
|
||||
});
|
||||
// Leaving the face is the only way to release elsewhere, so this covers the
|
||||
// press-drag-away-release case too.
|
||||
face.on('pointerout', () => {
|
||||
armed = false;
|
||||
press(false);
|
||||
opts.onClick();
|
||||
});
|
||||
face.on('pointerup', () => {
|
||||
const fire = armed;
|
||||
armed = false;
|
||||
press(false);
|
||||
if (fire) opts.onClick();
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
@ -19,6 +19,17 @@ const NEIGHBOURS: Record<string, string> = {
|
||||
/** Vowels get stretched when someone is really going for it. */
|
||||
const VOWELS = 'aeiou';
|
||||
|
||||
/**
|
||||
* Clamp to 0..1, treating a non-finite input as 0.
|
||||
*
|
||||
* A NaN reaching `text.y` makes Phaser silently render nothing, so it must never
|
||||
* leave this module. Callers guard their own frame deltas — this is the backstop,
|
||||
* not the fix.
|
||||
*/
|
||||
function clamp01(n: number): number {
|
||||
return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0;
|
||||
}
|
||||
|
||||
export interface TypoOptions {
|
||||
/** 0..1 — 0 leaves text untouched, 1 is barely language. */
|
||||
drunkenness: number;
|
||||
@ -36,7 +47,7 @@ export interface TypoOptions {
|
||||
* - stretched vowels ("yeahhh")
|
||||
*/
|
||||
export function drunkify(text: string, { drunkenness, rng }: TypoOptions): string {
|
||||
const d = Math.max(0, Math.min(1, drunkenness));
|
||||
const d = clamp01(drunkenness);
|
||||
if (d <= 0) return text;
|
||||
|
||||
// Below ~0.25 people type fine. Above that it ramps quickly.
|
||||
@ -84,14 +95,16 @@ export function drunkify(text: string, { drunkenness, rng }: TypoOptions): strin
|
||||
*/
|
||||
export function typedLength(text: string, elapsedMs: number, charMs: number, drunkenness = 0): number {
|
||||
if (charMs <= 0) return text.length;
|
||||
const slow = 1 + Math.max(0, Math.min(1, drunkenness)) * 1.2;
|
||||
const raw = elapsedMs / (charMs * slow);
|
||||
return Math.max(0, Math.min(text.length, Math.floor(raw)));
|
||||
const slow = 1 + clamp01(drunkenness) * 1.2;
|
||||
const raw = Math.floor(elapsedMs / (charMs * slow));
|
||||
if (Number.isNaN(raw)) return 0;
|
||||
return Math.max(0, Math.min(text.length, raw));
|
||||
}
|
||||
|
||||
/** Per-character vertical wobble (px) for drunk render mode. Deterministic. */
|
||||
export function wobbleAt(index: number, drunkenness: number, phase: number): number {
|
||||
const amp = Math.max(0, Math.min(1, drunkenness)) * 1.6;
|
||||
const amp = clamp01(drunkenness) * 1.6;
|
||||
if (amp <= 0) return 0;
|
||||
return Math.sin(phase + index * 0.9) * amp;
|
||||
const y = Math.sin(phase + index * 0.9) * amp;
|
||||
return Number.isNaN(y) ? 0 : y;
|
||||
}
|
||||
|
||||
200
tests/filterCurve.test.ts
Normal file
200
tests/filterCurve.test.ts
Normal 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
212
tests/scheduling.test.ts
Normal 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
211
tests/typo.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user