Give the menus a theme

background-music.wav loops under the title screen and the court picker, ducks
out when a court is picked, and fades back in at the death summary — the end
screen is a menu too, so the zombie bed ducks out as this one comes in.

It starts at BOOT, not on the first tap, so the title screen already has music.
That needed loopBed to call audio() before its already-started early return: the
theme plays into a context the autoplay policy holds suspended, and the call
that wakes it is a second menuMusic() from the first tap, which used to return
at the beds.has guard without reaching AC.resume().

fadeBed also now records a duck for a bed that is still loading, and loopBed
starts that bed silent. An 8 MB file ducked mid-fetch would otherwise come up at
full level, seconds later, over the match. Same latent bug applied to zombieDuck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-01 10:42:10 +02:00
1 parent fa30fb94c4
commit 60a74f6a99
6 files changed
+51 -8

No files matched your search

+16
View File
@@ -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
Binary file not shown.
+21 -3
View File
@@ -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
+5 -2
View File
@@ -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);
+1 -1
View File
@@ -117,6 +117,6 @@
<div id="courts"><h2>CHOOSE YOUR COURT</h2></div>
</div>
</div>
<script type="module" src="main.js?v=81"></script>
<script type="module" src="main.js?v=83"></script>
</body>
</html>
+8 -2
View File
@@ -7,7 +7,7 @@ import { TUNE, COURT, COURTS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight } from './gameplay.js';
import { fitViewport, render, proj, RES } from './render.js';
import { voice, ambient } from './audio.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer } from './zombies.js';
import { cv } from './dom.js';
import './input.js';
@@ -52,10 +52,16 @@ for (const c of COURTS) {
b.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); start(c.id); });
courts.appendChild(b);
}
function openPicker() { if (intro.parentNode) intro.classList.add('picking'); }
menuMusic(); // the title screen has a theme. Silent until the first tap on a phone — see loopBed.
function openPicker() {
if (!intro.parentNode) return;
intro.classList.add('picking');
menuMusic(); // ...and this call is the one that resumes it: the tap IS the gesture
}
function start(id) {
if (!intro.parentNode) return;
selectCourt(id);
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
}
// a tap anywhere on the splash opens the picker; on the picker itself only the buttons act, so a