// 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, UMP_H, UMP_X, EATEN_RESTART } from './config.js'; import { P, O, ball, score, G, setupPoint } 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 { 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 const dt = Math.min(0.04, (now - last) / 1000); last = now; update(dt); render(); } 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: first tap dismisses it, unlocks audio (iOS needs the gesture) and starts play const intro = document.getElementById('intro'); function dismissIntro() { if (!intro.parentNode) return; intro.remove(); ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture } intro.addEventListener('pointerdown', e => { e.preventDefault(); dismissIntro(); }); addEventListener('keydown', () => dismissIntro(), { once: true }); // 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; } };