diff --git a/HANDOFF.md b/HANDOFF.md index 46d2d6a..8081bd7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -442,6 +442,22 @@ Three courts, picked on a second start-screen stage. A court is pure DATA — `C Not done: clay ball-marks (the `gore` decal loop already draws flat ground ellipses, so a pale scuff per bounce is one call), per-court bounce SOUND, per-court crowd density. +**Menu theme** (`background-music.wav`, `menuMusic`/`menuDuck` in audio.js). Started at BOOT so the +title screen has music, ducked out when a court is picked, faded back in at `EATEN_SUMMARY` — the +death summary is the end menu, and the zombie bed ducks out as it comes in. Two things this cost: + +- `loopBed` now calls `audio()` BEFORE its already-started early return. The theme starts before any + gesture exists, so on a phone it plays into a context the autoplay policy holds suspended; the + call that wakes it is a second `menuMusic()` from the first tap, and that used to return at the + `beds.has` guard without ever reaching the `AC.resume()` inside `audio()`. +- `fadeBed` records a duck for a bed that is STILL LOADING (`ducked`), and `loopBed` starts that + bed's gain at 0. Without it, a multi-megabyte file ducked mid-fetch comes up at full level several + seconds later, over the match. + +`background-music.wav` is 8.3 MB and now downloads on every page load. Converting it to m4a (as the +umpire lines already are) would cut that ~10x; `loopBed` hardcodes `.wav`, so that is a two-line +change if it ever matters. + ## Session log: the serve is the gesture (2026-08-01) Oliver's notes on the art pass: the zombies are good, the runner "flickers very hard", and the diff --git a/assets/audio/background-music.wav b/assets/audio/background-music.wav new file mode 100644 index 0000000..bb283a3 Binary files /dev/null and b/assets/audio/background-music.wav differ diff --git a/audio.js b/audio.js index 141c24a..49fe8b5 100644 --- a/audio.js +++ b/audio.js @@ -76,17 +76,26 @@ 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 +const ducked = new Set(); // beds asked to duck while they were STILL LOADING — see fadeBed function loopBed(file, lowcut, vol) { + // audio() FIRST, before the already-started early return: it is also what RESUMES a context the + // browser suspended under its autoplay policy. The menu theme is started at boot, before any + // gesture exists, so it plays into a suspended context and stays silent — and the call that has + // to wake it up is a second menuMusic() from the first tap, which would otherwise return here + // without ever reaching audio(). + const ac = audio(); 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; + // start SILENT if it was ducked while loading: a multi-megabyte file takes seconds to fetch + // and decode, and a player who taps straight through the picker would otherwise have the + // menu theme come up over the match, several seconds late, at full level. + const g = ac.createGain(); g.gain.value = ducked.has(file) ? 0 : vol; src.connect(hp).connect(g).connect(ac.destination); src.start(); beds.set(file, { ac, g, vol }); }) @@ -94,17 +103,26 @@ function loopBed(file, lowcut, vol) { } 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 +// Menu theme: the splash, the court picker, the death summary. Barely highpassed — this one is +// MUSIC, not an atmosphere bed, so the low end is the point. Started at boot so it is already +// running by the time the title is on screen; on a phone the autoplay policy holds the context +// suspended until the first tap, and it becomes audible then (see loopBed). +export const menuMusic = () => loopBed('background-music', 30, 0.3); // 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) { + if (out) ducked.add(file); else ducked.delete(file); const b = beds.get(file); - if (!b) return; + if (!b) return; // still loading — the flag above is the level it will start at 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); +// The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up +// on the death screen costs nothing and can't miss its cue waiting on a re-fetch. +export const menuDuck = out => fadeBed('background-music', out); let sysVoice = null; export function say(text, delay = 0) { // fallback when a recorded umpire line is missing diff --git a/gameplay.js b/gameplay.js index ca0cddc..840583d 100644 --- a/gameplay.js +++ b/gameplay.js @@ -1,8 +1,8 @@ // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. -import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; +import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, 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, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js'; +import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } 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, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed } from './zombies.js'; @@ -920,6 +920,9 @@ export function update(dt) { const was = G.eatenT; G.eatenT += dt; if (G.eatenT < EATEN_BURST) eatenBleed(dt); // phase A: they are still tearing at you if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore + // phase C: the summary IS the end menu — hand the soundtrack back to the menu theme. Not at + // killPlayer: the death itself already has the massacre call and the crowd's groan over it. + if (was < EATEN_SUMMARY && G.eatenT >= EATEN_SUMMARY) { zombieDuck(true); menuDuck(false); } if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.boomFlash > 0) G.boomFlash -= dt; updateZombies(dt); diff --git a/index.html b/index.html index 87a3c53..7ae53e8 100644 --- a/index.html +++ b/index.html @@ -117,6 +117,6 @@