Sound: recorded SFX layer + a touch-easier CPU

Wire the new assets/audio/*.wav samples over the synth SFX:
- Ball hits: per-side tennis-player / tennis-opponent on serve & return,
  now with a stadium reverb tail (same convolver as umpire/aww).
- Crowd: tennis-applaus on your points/games (louder on big moments);
  tennis-aww when the opponent scores or you're eaten (reverb tail).
- Ambient bed: tennis-ambient-loop from first tap, 500Hz lowcut, low vol.
- Zombie music: zombie-mode loop when splatter kicks in (800Hz lowcut),
  fades out for the 1:1 sudden-death duel and back in after.
- Headshots: random headshot-1/-2 on every zombie kill (killZombie).

audio.js: sfx() one-shot player w/ rate+reverb opts, loopBed() beds with
duck/fade, aww() wrapper, hotter reverb send on the umpire voice.

Also: normal opponent aiFlub 0.09 -> 0.14 (a little easier to beat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 4.8 committed 2026-07-13 20:33:29 +02:00
1 parent 88e46a62b8
commit eacad1ffca
13 files changed
+75 -13

No files matched your search

+59 -1
View File
@@ -29,7 +29,9 @@ export function voice(name, delay = 0, rate = 1) {
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); src.connect(reverb(ac)); // dry + center-court echo
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]);
@@ -40,6 +42,62 @@ export function voice(name, delay = 0, rate = 1) {
.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 {