// 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'); }
// The overlay is a four-stage machine: splash -> menu -> {courts, tutorial}. One overlay throughout,
// swapped by data-stage, so the court never flashes between screens.
const intro = document.getElementById('intro');
const lastCourt = (() => { try { return localStorage.getItem('court'); } catch { return null; } })();
let picked = lastCourt || COURTS[0].id;
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
// --- court rows, built from the table
const courtList = document.getElementById('courtList');
for (const c of COURTS) {
const b = document.createElement('button');
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.innerHTML = `
${c.name}${c.sub}`;
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
// you no way to look at the three of them and no way back out of the screen.
b.addEventListener('click', () => {
picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b);
});
courtList.appendChild(b);
}
function start() {
if (!intro.parentNode) return;
selectCourt(picked);
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
}
document.getElementById('mStart').addEventListener('click', () => go('courts'));
document.getElementById('mTut').addEventListener('click', () => { tutPage = 0; drawTut(); go('tutorial'); });
document.getElementById('cStart').addEventListener('click', start);
document.getElementById('cBack').addEventListener('click', () => go('menu'));
// --- the tutorial, narrated by Marvin the mop boy. Pure data: a page is a Marvin pose, an optional
// picture already in assets/, and his lines.
const TUT = [
{ pose: 1, say: ["I'm MARVIN. I mop the court.",
"Lately there is a great deal more to mop.",
"Let me show you how this goes."] },
{ pose: 2, shot: 'court_grass', say: ["First it is just TENNIS.",
"TAP once to toss, tap again to serve.",
"FLICK toward where you want it.",
"DRAG to run — though he mostly runs himself."] },
{ pose: 4, say: ["Then, at some point, the other fellow...",
"stops being the other fellow.",
"Nobody stops the match. Nobody ever does."] },
{ pose: 2, shot: 'x_howto', say: ["Now you do not rally. You AIM.",
"PULL BACK from your man and let go.",
"The further you pull, the harder it lands.",
"A soft one only makes them cross."] },
{ pose: 2, shot: 'w_bomb', say: ["I bring tins out to the rack down there.",
"BOMBS, TRIPLES, FIRE, and the GUN.",
"Tap the rack to load one, then serve it.",
"Do try not to hit me with them."] },
{ pose: 3, say: ["Sometimes the umpire calls order back.",
"One point, tennis rules, against one of them.",
"The lines are live again. Do not miss."] },
{ pose: 1, say: ["That is the lot. Mind the deep ones.",
"And if it all goes wrong —",
"I will be along with the mop."] },
];
let tutPage = 0;
const tSay = document.getElementById('tSay'), tMarvin = document.getElementById('tMarvin');
const tDots = document.getElementById('tDots'), tNext = document.getElementById('tNext');
function drawTut() {
const p = TUT[tutPage];
tMarvin.src = `assets/marvin${p.pose}.png?v=91`;
tSay.innerHTML = (p.shot ? `
` : '')
+ p.say.map(l => `
${l}
`).join(''); tDots.textContent = TUT.map((_, i) => i === tutPage ? '●' : '·').join(''); tNext.textContent = tutPage === TUT.length - 1 ? 'DONE' : 'NEXT'; } tNext.addEventListener('click', () => { if (tutPage < TUT.length - 1) { tutPage++; drawTut(); } else go('menu'); }); document.getElementById('tBack').addEventListener('click', () => { if (tutPage > 0) { tutPage--; drawTut(); } else go('menu'); }); // A tap anywhere on the SPLASH moves to the menu — and that tap is the gesture that unlocks audio, // which is why the theme is not started at boot any more. Every later screen has real buttons, so // the overlay itself must not swallow taps beside them. intro.addEventListener('pointerdown', e => { if (intro.dataset.stage !== 'splash') return; e.preventDefault(); go('menu'); }); // Coming back from a run: the death screen reloads (an honest full reset) and asks, through // sessionStorage, to land on the MENU rather than the splash — you have already seen the title. try { if (sessionStorage.getItem('splatterStage') === 'menu') { sessionStorage.removeItem('splatterStage'); go('menu'); } } catch {} // 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, per stage: a key leaves the splash; on the court screen 1..N picks and Enter starts; // Escape backs out. Not `once` any more — there are four stages to get through. addEventListener('keydown', e => { if (!intro.parentNode) return; const stage = intro.dataset.stage; if (stage === 'splash') return go('menu'); if (e.key === 'Escape') return go(stage === 'menu' ? 'splash' : 'menu'); if (stage === 'menu') { if (e.key === 'Enter' || e.key === ' ') go('courts'); return; } if (stage === 'tutorial') { if (e.key === 'Enter' || e.key === ' ') tNext.click(); return; } const n = parseInt(e.key, 10); if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click(); else if (e.key === 'Enter' || e.key === ' ') start(); }); // 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). // The reload IS the reset (nothing here has to unwind a run by hand); the flag is what makes it // come back to the menu instead of the title screen. const maybeRestart = () => { if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return; try { sessionStorage.setItem('splatterStage', 'menu'); } catch {} 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; } };