m.in (0..1 over TUNE.modalIn, 0.34 s) is the entrance clock, ticked inside update()'s modal early-return — the one thing that animates while the world is frozen. Icon and title fly in from the right trailing two ghost copies as a speed streak, the body lines cascade in from the left one after another, everything on easeOutBack so it overshoots and settles rather than decelerating politely into place. The dim washes in over the first third. A descending noise sweep on arrival (swish is parameterised now — same six lines of Web Audio, opposite reading) and a low pock as it lands. Every x is rounded: a fractional x on a nearest-neighbour scaled image re-snaps its pixel columns, which is the boiling that once read as HUD flicker. Dismissal is gated on the entrance: while m.in < 1 dismissModal swallows the gesture but doesn't clear the modal, so a tap already in the air when a popup appears can't skip it — and swallowing rather than returning false is what keeps that tap from falling through into a serve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
// All sound: WebAudio context, synthesized SFX, recorded umpire lines, speech fallback.
|
|
|
|
let AC = null;
|
|
export const audio = () => {
|
|
if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
|
|
if (AC.state === 'suspended') AC.resume();
|
|
return AC;
|
|
};
|
|
|
|
// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio —
|
|
// works on mobile where speechSynthesis is unreliable. say() remains the fallback.
|
|
const VOICE = {};
|
|
let reverbNode = null;
|
|
function reverb(ac) { // stadium PA tail: exponentially decaying noise as impulse response
|
|
if (reverbNode) return reverbNode;
|
|
const len = ac.sampleRate * 1.3, ir = ac.createBuffer(2, len, ac.sampleRate);
|
|
for (let c = 0; c < 2; c++) {
|
|
const d = ir.getChannelData(c);
|
|
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.8);
|
|
}
|
|
reverbNode = ac.createConvolver(); reverbNode.buffer = ir;
|
|
const wet = ac.createGain(); wet.gain.value = 0.35;
|
|
reverbNode.connect(wet).connect(ac.destination);
|
|
return reverbNode;
|
|
}
|
|
|
|
export function voice(name, delay = 0, rate = 1) {
|
|
const ac = audio();
|
|
const play = buf => {
|
|
const src = ac.createBufferSource(); src.buffer = buf;
|
|
src.playbackRate.value = rate; // < 1 = slower and lower — the umpire's dark register
|
|
src.connect(ac.destination); // dry
|
|
const send = ac.createGain(); send.gain.value = 1.8; // umpire rides a wetter PA tail than the crowd sfx
|
|
src.connect(send).connect(reverb(ac)); // extra reverb send — the big-stadium announcer echo
|
|
src.start(ac.currentTime + delay);
|
|
};
|
|
if (VOICE[name]) return play(VOICE[name]);
|
|
fetch('assets/audio/' + name + '.m4a')
|
|
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
|
|
.then(ab => ac.decodeAudioData(ab))
|
|
.then(buf => { VOICE[name] = buf; play(buf); })
|
|
.catch(() => say(name.replace(/_/g, ' '), delay));
|
|
}
|
|
|
|
// Recorded one-shot SFX (assets/audio/tennis-*.wav): ball hits, applause, the crowd's aww.
|
|
// Same load-once-then-cache pattern as voice(), but dry (no PA reverb) and with a volume knob.
|
|
const SFX = {};
|
|
export function sfx(name, vol = 1, opts = {}) {
|
|
const ac = audio();
|
|
const play = buf => {
|
|
const src = ac.createBufferSource(); src.buffer = buf;
|
|
if (opts.rate) src.playbackRate.value = opts.rate; // < 1 stretches it a touch longer (and lower)
|
|
const g = ac.createGain(); g.gain.value = vol;
|
|
src.connect(g).connect(ac.destination);
|
|
if (opts.reverb) g.connect(reverb(ac)); // wet stadium tail on top of the dry sample
|
|
src.start();
|
|
};
|
|
if (SFX[name]) return play(SFX[name]);
|
|
fetch('assets/audio/' + name + '.wav')
|
|
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
|
|
.then(ab => ac.decodeAudioData(ab))
|
|
.then(buf => { SFX[name] = buf; play(buf); })
|
|
.catch(() => {}); // missing sample = silence, never a thrown error mid-rally
|
|
}
|
|
|
|
// the crowd's groan — stretched a touch and pushed through the PA reverb for a longer, wetter sigh
|
|
export function aww(vol = 1) { sfx('tennis-aww', vol * 0.5, { reverb: true }); }
|
|
|
|
// Looping background beds (scene atmosphere, mode music): each starts once, highpass 'lowcut' + quiet gain.
|
|
const beds = new Map(); // file -> {ac, g, vol} once playing (null while loading); see fadeBed
|
|
function loopBed(file, lowcut, vol) {
|
|
if (beds.has(file)) return;
|
|
beds.set(file, null);
|
|
const ac = audio();
|
|
fetch('assets/audio/' + file + '.wav')
|
|
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
|
|
.then(ab => ac.decodeAudioData(ab))
|
|
.then(buf => {
|
|
const src = ac.createBufferSource(); src.buffer = buf; src.loop = true;
|
|
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = lowcut;
|
|
const g = ac.createGain(); g.gain.value = vol;
|
|
src.connect(hp).connect(g).connect(ac.destination); src.start();
|
|
beds.set(file, { ac, g, vol });
|
|
})
|
|
.catch(() => beds.delete(file)); // failed start (decode/network) can retry next call
|
|
}
|
|
export const ambient = () => loopBed('tennis-ambient-loop', 500, 0.035); // whole-scene atmosphere, from first tap
|
|
export const zombieMusic = () => loopBed('zombie-mode', 800, 0.2); // background music once splatter kicks in
|
|
|
|
// duck a bed to silence (out=true) or back to its base level over `secs` — the sudden-death duel mutes the music
|
|
function fadeBed(file, out, secs = 0.7) {
|
|
const b = beds.get(file);
|
|
if (!b) return;
|
|
const t = b.ac.currentTime;
|
|
b.g.gain.cancelScheduledValues(t);
|
|
b.g.gain.setValueAtTime(b.g.gain.value, t); // anchor at current level so a mid-fade toggle stays smooth
|
|
b.g.gain.linearRampToValueAtTime(out ? 0 : b.vol, t + secs);
|
|
}
|
|
export const zombieDuck = out => fadeBed('zombie-mode', out);
|
|
|
|
let sysVoice = null;
|
|
export function say(text, delay = 0) { // fallback when a recorded umpire line is missing
|
|
try {
|
|
if (!sysVoice) sysVoice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null;
|
|
const u = new SpeechSynthesisUtterance(text);
|
|
if (sysVoice) u.voice = sysVoice; u.rate = 0.88; u.pitch = 0.75; u.volume = 0.9;
|
|
setTimeout(() => speechSynthesis.speak(u), delay * 1000);
|
|
} catch (e) {}
|
|
}
|
|
|
|
function noiseBuf(ac, len) {
|
|
const b = ac.createBuffer(1, ac.sampleRate * len, ac.sampleRate), d = b.getChannelData(0);
|
|
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
|
return b;
|
|
}
|
|
|
|
// Filtered noise sweep. Default = the racket cutting air (rising, short) — the sound of a whiff.
|
|
// The sweep is parameterised because a DESCENDING, longer one is a panel swooshing in and settling:
|
|
// same six lines of Web Audio, opposite reading. See whoosh() below.
|
|
export function swish(vol = 0.3, f0 = 1600, f1 = 3200, dur = 0.16) {
|
|
try {
|
|
const ac = audio(), t = ac.currentTime;
|
|
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, dur);
|
|
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(f0, t);
|
|
bp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.75); bp.Q.value = 1.5;
|
|
const g = ac.createGain();
|
|
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3);
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
|
src.connect(bp).connect(g).connect(ac.destination); src.start();
|
|
} catch (e) {}
|
|
}
|
|
export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling
|
|
|
|
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
|
|
try {
|
|
const ac = audio(), t = ac.currentTime;
|
|
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.06);
|
|
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 1.2;
|
|
const g = ac.createGain();
|
|
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.07);
|
|
src.connect(bp).connect(g).connect(ac.destination); src.start();
|
|
const o = ac.createOscillator(), og = ac.createGain(); // low thump body
|
|
o.frequency.setValueAtTime(180, t); o.frequency.exponentialRampToValueAtTime(70, t + 0.08);
|
|
og.gain.setValueAtTime(vol * 0.5, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
|
|
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.1);
|
|
} catch (e) {}
|
|
}
|
|
|
|
export function splat(vol = 0.7) { // wet burst — a zombie meets a first serve
|
|
try {
|
|
const ac = audio(), t = ac.currentTime;
|
|
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.22);
|
|
const lp = ac.createBiquadFilter(); lp.type = 'lowpass';
|
|
lp.frequency.setValueAtTime(1400, t); lp.frequency.exponentialRampToValueAtTime(180, t + 0.2);
|
|
const g = ac.createGain();
|
|
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
|
|
src.connect(lp).connect(g).connect(ac.destination); src.start();
|
|
const o = ac.createOscillator(), og = ac.createGain(); // body thud
|
|
o.frequency.setValueAtTime(120, t); o.frequency.exponentialRampToValueAtTime(45, t + 0.15);
|
|
og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
|
|
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18);
|
|
} catch (e) {}
|
|
}
|
|
|
|
export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filtered noise blast
|
|
try {
|
|
const ac = audio(), t = ac.currentTime;
|
|
const o = ac.createOscillator(), og = ac.createGain(); // 80Hz sine diving to 30Hz
|
|
o.frequency.setValueAtTime(80, t); o.frequency.exponentialRampToValueAtTime(30, t + 0.35);
|
|
og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
|
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.42);
|
|
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.4); // the blast
|
|
const lp = ac.createBiquadFilter(); lp.type = 'lowpass';
|
|
lp.frequency.setValueAtTime(2200, t); lp.frequency.exponentialRampToValueAtTime(200, t + 0.35);
|
|
const g = ac.createGain();
|
|
g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
|
src.connect(lp).connect(g).connect(ac.destination); src.start(t);
|
|
} catch (e) {}
|
|
}
|
|
|
|
export function shriek(vol = 0.35) { // a runner announces itself — harsh rising cry, the fairness contract
|
|
try {
|
|
const ac = audio(), t = ac.currentTime, dur = 0.7;
|
|
const o = ac.createOscillator(); o.type = 'sawtooth';
|
|
o.frequency.setValueAtTime(300, t);
|
|
o.frequency.exponentialRampToValueAtTime(950, t + 0.25);
|
|
o.frequency.exponentialRampToValueAtTime(500, t + dur);
|
|
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1400; bp.Q.value = 0.8;
|
|
const g = ac.createGain();
|
|
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.06);
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
|
o.connect(bp).connect(g).connect(ac.destination); o.start(t); o.stop(t + dur + 0.05);
|
|
} catch (e) {}
|
|
}
|
|
|
|
export function moan(vol = 0.1) { // distant undead ambience
|
|
try {
|
|
const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8;
|
|
const o = ac.createOscillator(); o.type = 'sawtooth';
|
|
const f0 = 65 + Math.random() * 45;
|
|
o.frequency.setValueAtTime(f0, t);
|
|
o.frequency.linearRampToValueAtTime(f0 * 1.25, t + dur * 0.4);
|
|
o.frequency.linearRampToValueAtTime(f0 * 0.9, t + dur);
|
|
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 320;
|
|
const g = ac.createGain();
|
|
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3);
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
|
o.connect(lp).connect(g).connect(ac.destination); o.start(t); o.stop(t + dur + 0.05);
|
|
} catch (e) {}
|
|
}
|
|
|
|
export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized claps; swap for a sample if it reads flat
|
|
try {
|
|
const ac = audio(), t0 = ac.currentTime, n = 90 * seconds;
|
|
for (let i = 0; i < n; i++) {
|
|
const t = t0 + Math.random() * seconds;
|
|
const env = Math.min(1, 4 * (1 - Math.abs(2 * (t - t0) / seconds - 1))); // fade in/out
|
|
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.025);
|
|
const bp = ac.createBiquadFilter(); bp.type = 'bandpass';
|
|
bp.frequency.value = 1000 + Math.random() * 1600; bp.Q.value = 2.5;
|
|
const g = ac.createGain();
|
|
g.gain.setValueAtTime(vol * env * (0.3 + Math.random() * 0.7), t);
|
|
g.gain.exponentialRampToValueAtTime(0.001, t + 0.03);
|
|
src.connect(bp).connect(g).connect(ac.destination); src.start(t);
|
|
}
|
|
} catch (e) {}
|
|
}
|