diff --git a/assets/audio/headshot-1.wav b/assets/audio/headshot-1.wav new file mode 100644 index 0000000..b28d1cb Binary files /dev/null and b/assets/audio/headshot-1.wav differ diff --git a/assets/audio/headshot-2.wav b/assets/audio/headshot-2.wav new file mode 100644 index 0000000..c0a116d Binary files /dev/null and b/assets/audio/headshot-2.wav differ diff --git a/assets/audio/tennis-ambient-loop.wav b/assets/audio/tennis-ambient-loop.wav new file mode 100644 index 0000000..c2434e0 Binary files /dev/null and b/assets/audio/tennis-ambient-loop.wav differ diff --git a/assets/audio/tennis-applaus.wav b/assets/audio/tennis-applaus.wav new file mode 100644 index 0000000..820a771 Binary files /dev/null and b/assets/audio/tennis-applaus.wav differ diff --git a/assets/audio/tennis-aww.wav b/assets/audio/tennis-aww.wav new file mode 100644 index 0000000..540ddad Binary files /dev/null and b/assets/audio/tennis-aww.wav differ diff --git a/assets/audio/tennis-opponent.wav b/assets/audio/tennis-opponent.wav new file mode 100644 index 0000000..37ca9f8 Binary files /dev/null and b/assets/audio/tennis-opponent.wav differ diff --git a/assets/audio/tennis-player.wav b/assets/audio/tennis-player.wav new file mode 100644 index 0000000..c829e41 Binary files /dev/null and b/assets/audio/tennis-player.wav differ diff --git a/assets/audio/zombie-mode.wav b/assets/audio/zombie-mode.wav new file mode 100644 index 0000000..148feca Binary files /dev/null and b/assets/audio/zombie-mode.wav differ diff --git a/audio.js b/audio.js index b0688e8..b09c9da 100644 --- a/audio.js +++ b/audio.js @@ -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 { diff --git a/config.js b/config.js index 08a9f22..cf3c775 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/gameplay.js b/gameplay.js index b60df32..44bee01 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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; diff --git a/main.js b/main.js index aee52fb..a67a026 100644 --- a/main.js +++ b/main.js @@ -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 }); diff --git a/zombies.js b/zombies.js index 9260897..6f8f60c 100644 --- a/zombies.js +++ b/zombies.js @@ -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)