// 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, TOURS, UMP_H, UMP_X, EATEN_RESTART } from './config.js'; import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight , toggleTestMode } from './gameplay.js'; import { fitViewport, render, proj, RES, SPRV } 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 , spawnBarrel } from './zombies.js'; import { cv } from './dom.js'; import * as stats from './stats.js'; import './input.js'; let last = performance.now(); // T.pause(true) freezes the WORLD but keeps drawing, so a value changed in devtools shows up on the // next frame without the scene moving on underneath you. Halting rAF instead (the old test recipe) // freezes the picture too, which is useless for looking at a shot. let paused = false; 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 if (paused) { last = now; render(); return; } 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 both faces so the canvas never paints a system-serif fallback frame if (document.fonts) for (const f of ['700 8px Silkscreen', '400 8px Silkscreen', '400 20px Bangers']) document.fonts.load(f); // The overlay is a four-stage machine: splash -> {setup, stats, ranks}. The title screen IS the // menu — a separate menu stage behind it showed the same logo on the same backdrop and read as the // title screen failing to advance. One overlay throughout, swapped by data-stage, so the court never // flashes between screens; HIDDEN during play rather than removed, so a restart puts it back. const intro = document.getElementById('intro'); const menuUp = () => !intro.hidden; const remembered = k => { try { return localStorage.getItem(k); } catch { return null; } }; let picked = remembered('court') || COURTS[0].id; let pickedTour = remembered('tour') || TOURS[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 // --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows — // the setup screen picks WHO and WHERE, and nothing on it commits you. const tourList = document.getElementById('tours'); for (const t of TOURS) { const b = document.createElement('button'); b.className = t.id === pickedTour ? 'on' : ''; b.innerHTML = `${t.name}`; b.addEventListener('click', () => { pickedTour = t.id; for (const el of tourList.children) el.classList.toggle('on', el === b); summary(); }); tourList.appendChild(b); } // --- court rows, built from the table. // The two meters come out of the court's OWN bounce numbers rather than a pair of hand-written // ratings, so a physics change moves the bars with it and the screen can never advertise a court // that plays differently from what it says. // SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest // BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest // The two divisors just stretch those narrow real ranges across a full bar; they carry no physics. const meter = (label, v, cls = '') => `
${label}
`; 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}` + meter('SPEED', (c.bounceXZ - 0.62) / 0.26) + meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b') + ''; // 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); summary(); }); courtList.appendChild(b); } // what the setup footer says you are about to play, and what it is worth beating function summary() { const t = TOURS.find(x => x.id === pickedTour) || TOURS[0]; const c = COURTS.find(x => x.id === picked) || COURTS[0]; const best = stats.total().best; document.getElementById('sSum').innerHTML = `${t.name.replace(' SINGLES', '')} · ${c.name}` + `${best ? best + (best === 1 ? ' GAME' : ' GAMES') + ' TO BEAT' : 'FIRST RUN'}`; } // the splash figures. Re-read every time the splash comes up, because a run has just ended and // both numbers have moved. function refreshSplash() { const { kills, best } = stats.total(); document.getElementById('sBest').textContent = best ? best + (best === 1 ? ' GAME' : ' GAMES') : '—'; document.getElementById('sRank').textContent = '#' + stats.rank(kills).toLocaleString('en-GB'); document.getElementById('mKills').textContent = kills.toLocaleString('en-GB') + ' KILLS'; summary(); } function start() { if (!menuUp()) return; selectTour(pickedTour); selectCourt(picked); menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture } let stPeriod = 'all'; const openStats = () => { stats.drawStats(stPeriod, SPRV); go('stats'); }; document.getElementById('mStart').addEventListener('click', () => go('setup')); document.getElementById('mStats').addEventListener('click', openStats); document.getElementById('mRanks').addEventListener('click', () => { stats.drawRanks(); go('ranks'); }); document.getElementById('cStart').addEventListener('click', start); for (const id of ['sBack', 'stBack', 'rkBack']) document.getElementById(id).addEventListener('click', () => go('splash')); // the three period tabs are one listener on the strip — the button carries the period in data-p document.getElementById('stTabs').addEventListener('click', e => { const b = e.target.closest('button'); if (!b) return; stPeriod = b.dataset.p; for (const el of e.currentTarget.children) el.classList.toggle('on', el === b); stats.drawStats(stPeriod, SPRV); }); refreshSplash(); // THE FIRST TAP ANYWHERE ON THE OVERLAY STARTS THE THEME, and does nothing else — the screens have // real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme // is not started at boot. menuMusic itself is a no-op once the bed exists (loopBed's beds.has // guard), so coming back from a run picks up the track already playing rather than restarting it. intro.addEventListener('pointerdown', () => menuMusic()); // 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. On the splash Enter/Space goes to setup and S opens the stats; on the setup // screen 1..N picks a court and Enter starts; Escape backs out to the splash from anywhere. addEventListener('keydown', e => { if (!menuUp()) return; menuMusic(); // a key is a gesture too — unlock the theme on a desktop that never taps const stage = intro.dataset.stage; if (e.key === 'Escape') return go('splash'); if (stage === 'splash') { if (e.key === 'Enter' || e.key === ' ') go('setup'); else if (e.key === 's' || e.key === 'S') openStats(); return; } if (stage !== 'setup') return; // stats and ranks are read-only: Escape is the whole interface 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(); }); // A run the player walks out on is still a run. killPlayer banks a death; this banks everything // else — the tab closed mid-match, the phone's browser killed in the background. `pagehide` is the // one that actually fires on iOS, where beforeunload/unload do not. endRun is idempotent and // no-ops on an empty run, so closing the tab at the menu writes nothing. addEventListener('pagehide', () => { if (G.mode === 'splatter') stats.endRun(G.runGames, G.sets[0], G.splatT, false); }); // eaten: any tap or key returns to the title — 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). // NOT a page reload any more: that reset everything honestly but also tore down the AudioContext, so // the theme restarted from silence. restartRun puts every bag back from a pristine copy, and the // bed that has been playing under the death summary simply carries on into the title screen. const maybeRestart = () => { if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return; restartRun(); refreshSplash(); // the run just ended: both splash figures have moved intro.hidden = false; go('splash'); }; cv.addEventListener('pointerdown', maybeRestart); addEventListener('keydown', maybeRestart); // hidden shortcuts: three taps on the UMPIRE summons zombie mode early, three on the SCOREBOARD // opens test mode (a medium wave plus every barrel weapon — see gameplay.toggleTestMode) let umpTaps = [], boardTaps = []; 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) { // the scoreboard const now = performance.now(); boardTaps = boardTaps.filter(t => now - t < 1600); boardTaps.push(now); if (boardTaps.length >= 3) { boardTaps = []; // taps one and two already toggled the miss-log twice, so it is back where it started — // this third one must NOT toggle it again, or every trip through test mode leaves the // overlay on behind you toggleTestMode(); } else G.logShow = !G.logShow; // a single tap is still the miss-log overlay return; } 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(); } }); // ---- A POSED SCENE for tuning a shot: `T.laserScene()`, or load with #laser. Six bodies of // different types at known depths, the laser armed, a pull held. // IT DOES NOT PAUSE, deliberately. The first version froze the world so the picture would hold // still — and then every TUNE value you changed did nothing visible, because nothing redrew. The // bodies are held still individually (v = 0) and spawning is off instead, so the picture is stable // while the render loop keeps running and an edit shows on the very next frame. function laserScene() { const intro = document.getElementById('intro'); if (intro) intro.hidden = true; paused = false; if (G.mode === 'tennis') forceZombies(); for (let i = 0; i < 600; i++) update(1 / 60); // ride the transition out into splatter G.modal = null; G.horde = null; G.interlude = false; G.changeover = 0; G.clearing = false; G.mgT = 0; G.gunId = null; G.beam = null; G.bossFight = 0; G.hp = G.hpMax = TUNE.playerHp; zombies.length = 0; pellets.length = 0; parts.length = 0; gore.length = 0; piles.length = 0; score.server = 'P'; setupPoint(); P.x = 0; TUNE.zMax = 0; // nothing new wanders into the shot for (const [x, z, v] of [[0, 20, 'a'], [0.75, 16, 'b'], [-0.85, 12, 'k'], [0.85, 8.5, 'g'], [-0.75, 5.5, 'h'], [0, 3, 'e']]) { spawnZombie(x, z, v); zombies[zombies.length - 1].v = 0; // held still: the scene is a photograph } G.rack.length = 0; G.temp = null; G.weapon = grantWeapon('laser'); G.modal = null; G.aim = { fx: 0, fy: -1, mag: 100 }; // a full pull straight up-court (fy is NEGATIVE) return 'posed and LIVE — change TUNE (T panel or console) and it redraws. T.laserFire() to shoot it.'; } // fire the posed shot, then pose it again so you can keep tuning function laserFire() { G.aim = null; score.server = 'P'; setupPoint(); keyPressed('Space'); return 'fired — T.laserScene() to re-pose'; } // a plain timer, not the load event: this module runs as a module, so `load` may already have gone if (location.hash === '#laser') setTimeout(laserScene, 600); requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, 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, stats, step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } };