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

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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 {
+1 -1
View File
@@ -14,7 +14,7 @@ export const TUNE = {
serveSpeed: 21, serveQualitySpeed: 7,
aimError: 2.4, // m of scatter at quality 0
perfectMs: 45, // |offset from window center| for PERFECT
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.14, // ~86% of returns land (was 0.09) — a touch easier to beat
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile)
zSpeed: 0.7, // zombie shamble speed m/s
+10 -7
View File
@@ -2,7 +2,7 @@
import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
import { audio, pock, swish, applause, voice } from './audio.js';
import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, spawnZombie, spawnRacketZombie, sweepCourt, zombies, explode, meleeSwipe, extras, parts } from './zombies.js';
@@ -106,7 +106,7 @@ function doHit(ch, quality, aim) {
G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
G.aiReactT = 0; // CPU hasn't read the shot yet
}
pock(0.9, 950);
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side
if (quality > 0.999) { G.hitstop = 0.05; G.flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); }
ch.pose = 'swing'; ch.poseT = 0;
}
@@ -162,7 +162,7 @@ function hitServe(sv, quality, aim = null) {
}
}
if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 };
G.phase = 'rally'; pock(1, 1100);
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
sv.pose = 'serve'; sv.poseT = 0;
if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; }
}
@@ -180,7 +180,7 @@ function scoreKey() {
}
export function cheer(seconds, vol) {
applause(seconds, vol);
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
crowd.cheer = Math.max(crowd.cheer, seconds);
}
@@ -203,6 +203,7 @@ function pointTo(side, reason) {
const gameOver = advancePoint(side);
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
sendBoy();
if (side === 'O') aww(0.8); // you dropped the point — the crowd sighs
if (reason) voice(reason);
if (gameOver) {
if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned) return startTransition();
@@ -232,7 +233,7 @@ export function splatPoint(side, quiet = false) {
(G.runGames > TUNE.ballChangeFirst && (G.runGames - TUNE.ballChangeFirst) % TUNE.ballChangeEvery === 0))
G.tinDue = true;
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
cheer(2.2, 0.5);
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
sweepCourt(); // the wave is OVER: survivors collapse where they stand — a game ends visibly
ball.active = false; // the beat owns the court
G.pendingGame = true; // 'GAME N' announces the next wave when play resumes
@@ -282,12 +283,13 @@ function deliverTin() {
export function resolveInterlude(side) {
if (!G.interlude) return;
G.interlude = false;
zombieDuck(false); // duel over — bring the music back up
ball.active = false; G.phase = 'over'; G.overT = 2.2;
if (side === 'P') {
cheer(3, 0.7);
deliverTin(); // win the point, win the next tin — on the spot
} else {
voice('game_opponent', 0.4);
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
score.games[1]++; G.runGames++; G.shameGame = true; // it goes on the scoreboard
if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
G.flash = { text: 'GAME. THEM.', t: 1.6 };
@@ -339,7 +341,7 @@ function transition(dt) {
once('turn', 3.4, () => { spawnZombie(O.x, O.z, 'c'); G.hideO = true; }); // the opponent... turns
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); });
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
once('go', 8.2, () => { G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); });
once('go', 8.2, () => { G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic(); });
}
function fault() {
@@ -653,6 +655,7 @@ export function update(dt) {
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) {
zombies[0].dueled = true; // one duel per opponent — after that it just rallies
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return
zombieDuck(true); // fade the zombie music out for the 1:1 duel
voice('play_resume', 0.2); voice('sudden_death', 2.4);
}
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
+2 -2
View File
@@ -7,7 +7,7 @@ import { TUNE, COURT, UMP_H, UMP_X } from './config.js';
import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint } from './gameplay.js';
import { fitViewport, render, proj, RES } from './render.js';
import { voice } from './audio.js';
import { voice, ambient } from './audio.js';
import { zombies, spawnZombie, piles, gore, parts, pellets, extras, explode } from './zombies.js';
import { cv } from './dom.js';
import './input.js';
@@ -27,7 +27,7 @@ setupPoint();
const intro = document.getElementById('intro');
function dismissIntro() {
if (!intro.parentNode) return;
intro.remove(); voice('quiet_please');
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
}
intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); });
addEventListener('keydown', () => dismissIntro(), { once: true });
+3 -2
View File
@@ -5,7 +5,7 @@
import { TUNE, COURT } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom, swish, shriek } from './audio.js';
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
@@ -64,6 +64,7 @@ function killZombie(i, quiet) {
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark,
}); }
splat();
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
sendBoy(zb.x, zb.z); // a boy sprints out to mop
if (!G.horde) splatPoint('P', quiet); // a horde is survival — it doesn't score, so it can't chain into itself
}
@@ -195,7 +196,7 @@ export function updateZombies(dt) {
t: 0.5 + Math.random() * 0.5,
});
splat(1);
voice('game_set_massacre', 0.8);
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans
G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience
G.horde = null; pellets.length = 0; extras.length = 0; G.mgT = 0; // everything stops; the eaten screen takes over
try { // the run is over — settle it against the best (games survived, sets as tiebreak)