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

+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;