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>
114 lines
7.2 KiB
JavaScript
114 lines
7.2 KiB
JavaScript
// Splatter Tennis — boot, main loop, intro overlay, and the window.T test hooks.
|
|
// World units: meters. z axis = court depth, 0 at FAR baseline, 23.77 at NEAR baseline.
|
|
// Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update)
|
|
// render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI)
|
|
|
|
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, 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';
|
|
|
|
let last = performance.now();
|
|
function frame(now) {
|
|
requestAnimationFrame(frame); // schedule FIRST: one bad frame must never kill the loop (a thrown error froze the game for good)
|
|
if (TUNE.fps && now - last < 1000 / TUNE.fps - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
|
|
let dt = Math.min(0.04, (now - last) / 1000); last = now;
|
|
// A chapter boss dies in slow motion. The clock that ENDS it runs at real speed, so the effect is
|
|
// always the same length in seconds however far the world has been slowed.
|
|
if (G.slowT > 0) { G.slowT -= dt; dt *= TUNE.bossSlowMo; }
|
|
update(dt); render();
|
|
}
|
|
|
|
fitViewport();
|
|
// iOS PWA cold start. Launched from the homescreen, WebKit resolves the viewport AND every env()
|
|
// safe-area inset once, against a provisional geometry, and never revisits them until the geometry
|
|
// is "exercised" — which is why the game only filled the screen after a rotate and back, and why
|
|
// waiting for an event could never work: nothing fires. Toggling viewport-fit off and on is the
|
|
// known WebKit workaround: it forces that recalculation without the user rotating anything.
|
|
// Standalone only, because that is the only mode with the bug.
|
|
if (navigator.standalone || matchMedia('(display-mode: standalone)').matches) {
|
|
const meta = document.querySelector('meta[name=viewport]'), real = meta.content;
|
|
meta.content = real.replace('viewport-fit=cover', 'viewport-fit=auto');
|
|
requestAnimationFrame(() => { meta.content = real; requestAnimationFrame(fitViewport); });
|
|
}
|
|
setupPoint();
|
|
// warm the pixel HUD font so the canvas never paints a system-serif fallback frame
|
|
if (document.fonts) { document.fonts.load('700 8px Silkscreen'); document.fonts.load('400 8px Silkscreen'); }
|
|
|
|
// intro overlay, two stages: the first tap opens court select, picking a court starts play and
|
|
// unlocks audio (iOS needs the gesture, and the SECOND tap is still a gesture — the pick is what
|
|
// has to unlock it, not the splash).
|
|
const intro = document.getElementById('intro');
|
|
const courts = document.getElementById('courts');
|
|
const lastCourt = (() => { try { return localStorage.getItem('court'); } catch { return null; } })();
|
|
for (const c of COURTS) {
|
|
const b = document.createElement('button');
|
|
b.className = c.id === (lastCourt || COURTS[0].id) ? 'on' : ''; // your last court comes up pre-picked
|
|
b.innerHTML = `<img src="assets/${c.tile}.png?v=81" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
|
|
b.addEventListener('pointerdown', e => { e.preventDefault(); e.stopPropagation(); start(c.id); });
|
|
courts.appendChild(b);
|
|
}
|
|
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
|
|
// stray tap beside a tile can't start a match on a court you didn't choose.
|
|
intro.addEventListener('pointerdown', e => { e.preventDefault(); openPicker(); });
|
|
// which build am I looking at? Push = deploy and the browser cannot see git, so the served file's
|
|
// own timestamp IS the version marker — it changes on every deploy, with nothing to remember to bump.
|
|
// Match it against `git log -1 --format=%cd` when a phone looks suspiciously out of date.
|
|
fetch('main.js', { method: 'HEAD' })
|
|
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
|
|
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
|
|
.catch(() => {}); // no header, no line — never a broken start screen
|
|
// keyboard: any key opens the picker, then 1..N picks a court and Enter/Space takes the
|
|
// highlighted one. Not `once` any more — it has two stages to get through.
|
|
addEventListener('keydown', e => {
|
|
if (!intro.parentNode) return;
|
|
if (!intro.classList.contains('picking')) { openPicker(); return; }
|
|
const n = parseInt(e.key, 10);
|
|
if (n >= 1 && n <= COURTS.length) start(COURTS[n - 1].id);
|
|
else if (e.key === 'Enter' || e.key === ' ') start(lastCourt || COURTS[0].id);
|
|
});
|
|
|
|
// eaten: any tap or key restarts — but only once the death animation has played out, so a
|
|
// panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
|
|
const maybeRestart = () => { if (!G.modal && G.mode === 'eaten' && G.eatenT > EATEN_RESTART) location.reload(); }; // ponytail: reload = honest full reset
|
|
cv.addEventListener('pointerdown', maybeRestart);
|
|
addEventListener('keydown', maybeRestart);
|
|
|
|
// hidden shortcut: tap the umpire three times to summon zombie mode early
|
|
let umpTaps = [];
|
|
cv.addEventListener('pointerdown', e => {
|
|
const r = cv.getBoundingClientRect();
|
|
const x = (e.clientX - r.left) / r.width * (cv.width / RES); // logical coords
|
|
const y = (e.clientY - r.top) / r.height * (cv.height / RES);
|
|
if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { G.logShow = !G.logShow; return; } // scoreboard tap = miss-log overlay
|
|
const u = proj(UMP_X, 0, COURT.netZ);
|
|
if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
|
|
const now = performance.now();
|
|
umpTaps = umpTaps.filter(t => now - t < 1600);
|
|
umpTaps.push(now);
|
|
if (umpTaps.length >= 3) { umpTaps = []; forceZombies(); }
|
|
});
|
|
|
|
requestAnimationFrame(frame);
|
|
|
|
// exposed for in-browser sanity checks and simulated playtests
|
|
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
|
|
step: dt => update(dt), render, press: keyPressed,
|
|
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|