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:
1 parent
fa30fb94c4
commit
60a74f6a99
6 files changed
+51
-8
No files matched your search
+16
@@ -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.
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user