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:
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.
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user