The overlay is a four-stage machine now — splash -> menu -> {courts, tutorial}
— swapped by data-stage while the backdrop and card frame stay mounted, so the
live court never flashes through between screens.
Court tiles SELECT only. Tapping one used to start the match on the spot, which
gave you no way to compare the three and no way back out; START GAME and BACK
are their own buttons now. Headlines are 15px against the 11px body, so a screen
reads as a screen rather than as a list.
The tutorial is a data table: a Marvin pose, an optional picture already in
assets/, and his lines. Seven pages covering tennis, the turn, the slingshot,
the rack and the duel. Marvin is four new frames off x_boymop1 — waving,
pointing, shrugging, and clutching his mop in terror.
The theme no longer starts at boot. A title screen that plays at you before you
have touched it is rude, and the autoplay policy would have held it silent until
that first tap anyway.
Death returns to the menu instead of the title. The reload stays — it is the
honest full reset — and a sessionStorage flag is what picks the stage on the way
back in.
Machine-gun rounds are tennis balls, so they are drawn as balls and take the
perspective. A fixed 4x4 fillRect was exactly as big at the far baseline as at
your feet, which read as square confetti pinned to the screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
15 KiB
JavaScript
267 lines
15 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;
|
|
}
|
|
|
|
// The umpire is one man with one microphone. Several triggers landing together used to play two or
|
|
// three lines on top of each other; a line that would start while he is still talking is DROPPED.
|
|
// The check happens at play time (not call time) so a first-play fetch/decode is judged fairly.
|
|
let voiceUntil = 0;
|
|
|
|
export function voice(name, delay = 0, rate = 1) {
|
|
const ac = audio();
|
|
const play = buf => {
|
|
const at = ac.currentTime + delay;
|
|
if (at < voiceUntil) return;
|
|
voiceUntil = at + buf.duration / rate + 0.2; // + a breath, so lines never butt against each other
|
|
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
|
|
const ducked = new Set(); // beds asked to duck while they were STILL LOADING — see fadeBed
|
|
function loopBed(file, lowcut, vol, loopEnd = 0) {
|
|
// audio() FIRST, before the already-started early return: it is also what RESUMES a context the
|
|
// browser suspended under its autoplay policy. The menu theme is started at boot, before any
|
|
// gesture exists, so it plays into a suspended context and stays silent — and the call that has
|
|
// to wake it up is a second menuMusic() from the first tap, which would otherwise return here
|
|
// without ever reaching audio().
|
|
const ac = audio();
|
|
if (beds.has(file)) return;
|
|
beds.set(file, null);
|
|
fetch('assets/audio/' + file) // full filename: the beds are a mix of .wav and .m4a, see below
|
|
.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;
|
|
// loopEnd = the source's TRUE length, for a bed that is compressed. AAC pads the last packet,
|
|
// and Chrome hands back a buffer ~17 ms longer than the master — silence spliced in at every
|
|
// seam, once a loop, on the title screen. Trimming the seam is what keeps the loop seamless.
|
|
// Zero (the default, and every uncompressed bed) leaves the whole buffer in play.
|
|
if (loopEnd) src.loopEnd = loopEnd;
|
|
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = lowcut;
|
|
// start SILENT if it was ducked while loading: a multi-megabyte file takes seconds to fetch
|
|
// and decode, and a player who taps straight through the picker would otherwise have the
|
|
// menu theme come up over the match, several seconds late, at full level.
|
|
const g = ac.createGain(); g.gain.value = ducked.has(file) ? 0 : 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.wav', 500, 0.035); // whole-scene atmosphere, from first tap
|
|
// ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is
|
|
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
|
|
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
|
|
export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
|
|
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
|
|
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
|
|
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
|
|
// the autoplay policy would have held it silent until that tap anyway.
|
|
// AAC, not WAV: 48 s of stereo music is 8.3 MB as a WAV and this one downloads on every page load,
|
|
// before the player has decided to play anything. 768 KB encoded, and the master is exactly 48.000 s
|
|
// — which is the loopEnd, because the decoded buffer is NOT (measured 48.0174 s in Chrome; see the
|
|
// padding note in loopBed). Re-encode the file and you must re-measure that number:
|
|
// fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration)
|
|
export const menuMusic = () => loopBed('background-music.m4a', 30, 0.5, 48);
|
|
|
|
// 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) {
|
|
if (out) ducked.add(file); else ducked.delete(file);
|
|
const b = beds.get(file);
|
|
if (!b) return; // still loading — the flag above is the level it will start at
|
|
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.m4a', out); // key must match loopBed's, filename and all
|
|
// The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up
|
|
// on the death screen costs nothing and can't miss its cue waiting on a re-fetch.
|
|
export const menuDuck = out => fadeBed('background-music.m4a', 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) {}
|
|
}
|