Oliver's design call for the setup screen: the mode buttons should POP — a bright full-bleed background symbolising each game mode, the description written over it. Four scenes rolled through the _famroll OpenRouter route, each anchored on its mode's own sprite for style, no text baked in: CLASSIC a sunny sports-poster serve under a blue summer sky ZOMBIE the turned opponent at an acid-green floodlit night net CUP the champion's silhouette looming over the gold trophy GORE the horde pouring over the net into a blood-red sunset Installed as mode_*.png at a third of the 2K roll, quantised to 128 colours (2.1 MB of menu art became 265 KB; pixel art quantises for free). The button is the banner now: art + scrim gradient as one background stack, caption bottom-left with a text shadow, and the unpicked modes sit desaturated and dimmed so the chosen one is the thing that glows — the menu's own "one bright thing means selected" rule, applied to pictures. The sprite-tile row and its `tile` field are gone from MODES; raw 2K rolls kept in _refcheck/modebtn for re-cuts. SPRV 114 -> 115, main.js v229. Verified in the browser: draw, select, summary line, no console errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
474 lines
29 KiB
JavaScript
474 lines
29 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, COURTS, TOURS, MODES, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } 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, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
|
||
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall } from './beats.js';
|
||
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
|
||
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
||
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, 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;
|
||
// CALM STATES RUN SLOW. The battery bill is fill-rate × frame-rate, and two screens can sit on a
|
||
// phone for minutes while fully static: a SETTLED modal (the world is frozen behind it — only the
|
||
// blink still moves) and the death summary once its fade and gore have played out. 20-odd fps
|
||
// there is invisible and thirds the drain; live play stays at TUNE.fps, untouched — the 60 was a
|
||
// feel decision and this deliberately never applies to anything that moves.
|
||
const calmFps = () => G.modal && G.modal.in >= 1 ? 20
|
||
: G.mode === 'eaten' && G.eatenT > EATEN_RESTART + 1 ? 24 : 0;
|
||
let lowPowerApplied = false; // the last lowPower state fitViewport was run under (T-panel flips it live)
|
||
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.lowPower >= 0.5) !== lowPowerApplied) { lowPowerApplied = TUNE.lowPower >= 0.5; fitViewport(); }
|
||
const cap = calmFps() || TUNE.fps;
|
||
if (cap && now - last < 1000 / cap - 2) return; // frame cap (battery): skip RAF ticks until it's our beat
|
||
// THE MENU IS OPAQUE (#intro: fixed, inset 0, solid background). Painting the whole court at RES
|
||
// density behind it — for the minutes a phone idles on the splash and stats screens — was pure
|
||
// battery burn nobody could see. The world holds still too; nothing on it predates the menu.
|
||
if (menuUp()) { last = now; return; }
|
||
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
|
||
|
||
// --- MODE tiles, built from config.MODES. The setup screen now picks WHAT, WHO and WHERE, and this
|
||
// is the first of the three because it is the one that changes the other two's meaning. Same
|
||
// "tap selects, SERVE UP starts" rule as everything else on the screen; the row markup is the court
|
||
// row's, reused wholesale (see the shared selectors in index.html).
|
||
let pickedMode = remembered('mode') || MODES[0].id;
|
||
const modeList = document.getElementById('modeList');
|
||
for (const m of MODES) {
|
||
const b = document.createElement('button');
|
||
b.className = 'mode' + (m.id === pickedMode ? ' on' : '');
|
||
// the scrim rides IN the background stack (gradient over art), so the caption needs no extra box
|
||
b.style.backgroundImage =
|
||
`linear-gradient(180deg, rgba(0,0,0,0) 34%, rgba(0,0,0,0.78)), url(assets/${m.art}.png?v=${SPRV})`;
|
||
b.innerHTML = `<span class="cb"><b>${m.name}</b><i>${m.sub}</i></span>`;
|
||
b.addEventListener('click', () => {
|
||
pickedMode = m.id;
|
||
try { localStorage.setItem('mode', m.id); } catch {} // come back to what you were playing
|
||
for (const el of modeList.children) el.classList.toggle('on', el === b);
|
||
summary();
|
||
});
|
||
modeList.appendChild(b);
|
||
}
|
||
|
||
// --- 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 = `<img src="assets/${t.tile}.png?v=${SPRV}" alt=""><b>${t.name}</b>`;
|
||
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 = '') =>
|
||
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${
|
||
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
|
||
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 = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
|
||
+ `<b>${c.name}</b><i>${c.sub}</i>`
|
||
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
|
||
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
|
||
+ '</span>';
|
||
// 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 m = MODES.find(x => x.id === pickedMode) || MODES[0];
|
||
// the mark to beat is the PICKED MODE's own, because the three of them do not measure the same
|
||
// thing — see the record-shape note in stats.endRun
|
||
const mode = m.id === 'zombie' ? stats.MODE_MATCH : m.id === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||
const best = stats.read('all', undefined, mode).longest;
|
||
const unit = m.id === 'zombie' ? (best === 1 ? ' FELLED' : ' FELLED')
|
||
: best === 1 ? ' GAME' : ' GAMES';
|
||
document.getElementById('sSum').innerHTML =
|
||
`<span>${m.name} · ${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
|
||
+ `<span>${best ? best + unit + ' TO BEAT' : 'FIRST RUN'}</span>`;
|
||
}
|
||
|
||
// The splash carries ONE number, on the STATS button. A boxed BEST RUN / WORLD RANK bar across the
|
||
// top came out of the mockup and went again: it sat on the best part of the art and said twice what
|
||
// the two buttons underneath already say. Re-read whenever the splash comes back up, because a run
|
||
// has just ended and the count has moved.
|
||
function refreshSplash() {
|
||
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
|
||
summary();
|
||
}
|
||
|
||
// `modeId` is the mode to drop into. It defaults to what the setup screen picked — but the BACK
|
||
// ROOM passes null, because those entries bring their own mode and a remembered ZOMBIE TENNIS was
|
||
// taking the court before startDrill/startBench/toggleTestMode could, so every one of them silently
|
||
// refused. The sprite bench found this the first time it was opened.
|
||
function start(modeId = pickedMode) {
|
||
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
|
||
// ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes
|
||
// differ by at this point: gore is the game as it always was, classic is the same tennis with the
|
||
// turn switched off, and zombie tennis is the match taking the court. Everything else about a mode
|
||
// lives in the code that reads G.classic / G.match, never here.
|
||
G.classic = modeId === 'classic';
|
||
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
|
||
if (modeId === 'cup') { intoSplatter(); startMatch(true); }
|
||
}
|
||
let stPeriod = 'all', stMode = stats.MODE_GORE;
|
||
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
|
||
// came to look at, and it saves a tap on the way in every single time
|
||
const openStats = () => {
|
||
stMode = pickedMode === 'zombie' ? stats.MODE_MATCH : pickedMode === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
|
||
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
|
||
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
|
||
};
|
||
document.getElementById('mStart').addEventListener('click', () => go('setup'));
|
||
document.getElementById('mStats').addEventListener('click', openStats);
|
||
document.getElementById('mRanks').addEventListener('click', () => { go('ranks'); stats.drawRanks(); });
|
||
|
||
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best kill
|
||
// count, on the tap that takes you off the death screen. Anything more often is a form standing
|
||
// between you and the next match.
|
||
const nbName = document.getElementById('nbName'), nbMsg = document.getElementById('nbMsg');
|
||
let nbRun = null;
|
||
function askName(run) {
|
||
nbRun = run;
|
||
document.getElementById('nbScore').innerHTML =
|
||
`<b>${run.kills.toLocaleString('en-GB')}</b> KILLS · <b>${run.games}</b> GAME${run.games === 1 ? '' : 'S'}`;
|
||
nbName.value = stats.savedName();
|
||
nbMsg.textContent = 'PUT IT ON THE WORLD BOARD.';
|
||
document.getElementById('nbSend').disabled = false;
|
||
go('newbest');
|
||
// focus only where a keyboard is already there. On a phone this would throw the soft keyboard up
|
||
// over the score you just earned, before you have read it.
|
||
if (!matchMedia('(pointer: coarse)').matches) nbName.focus();
|
||
}
|
||
document.getElementById('nbSkip').addEventListener('click', () => { nbRun = null; go('splash'); });
|
||
document.getElementById('nbSend').addEventListener('click', async () => {
|
||
const name = stats.tidyName(nbName.value);
|
||
if (!name) { nbMsg.textContent = 'A NAME FIRST.'; nbName.focus(); return; }
|
||
nbName.value = name; // show what is actually going on the board
|
||
if (!nbRun) return go('splash');
|
||
const btn = document.getElementById('nbSend');
|
||
btn.disabled = true; nbMsg.textContent = 'SENDING…'; // the double-tap posts twice otherwise
|
||
try {
|
||
const r = await stats.submit(name, nbRun.kills, nbRun.games);
|
||
nbRun = null;
|
||
go('ranks');
|
||
stats.drawRanks(r.scores); // the board came back with the reply — no second round trip
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
nbMsg.textContent = e.taken ? 'THAT NAME IS SPOKEN FOR. PICK ANOTHER.'
|
||
: 'THE BOARD DID NOT ANSWER. TRY AGAIN?';
|
||
if (e.taken) { nbName.select(); nbName.focus(); }
|
||
}
|
||
});
|
||
nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('nbSend').click(); });
|
||
// () => start(), NEVER `start` — addEventListener hands the listener a MouseEvent as its first
|
||
// argument, so `start(modeId = pickedMode)` never saw its default and modeId was an EVENT OBJECT.
|
||
// `G.classic = modeId === 'classic'` was therefore always false and the zombie branch never fired:
|
||
// SERVE UP dropped you into the gore mode whatever the setup screen said, and picking CLASSIC or
|
||
// ZOMBIE TENNIS did nothing at all. A default parameter only applies to `undefined`, and a click
|
||
// handler always passes something.
|
||
document.getElementById('cStart').addEventListener('click', () => start());
|
||
for (const id of ['sBack', 'stBack', 'rkBack', 'scBack'])
|
||
document.getElementById(id).addEventListener('click', () => go('splash'));
|
||
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
|
||
|
||
// ---- THE BACK ROOM: five taps in the TOP-LEFT CORNER of the splash (#hot, an invisible box in
|
||
// index.html), and nothing on the screen says so. It replaces the triple-tap on the in-play
|
||
// scoreboard, which sat on top of the miss-log tap and which the testteam kept opening by accident.
|
||
// The corner rather than the logo: on the way to PLAY MATCH your thumb crosses the middle of the
|
||
// splash all the time, and a hidden door you can open by accident is just a bug with a secret.
|
||
let logoTaps = [];
|
||
document.getElementById('hot').addEventListener('pointerdown', () => {
|
||
const now = performance.now();
|
||
logoTaps = logoTaps.filter(t => now - t < 2000);
|
||
logoTaps.push(now);
|
||
if (logoTaps.length >= 5) { logoTaps = []; go('secret'); }
|
||
});
|
||
|
||
// Both entries drop you into a REAL run rather than a special mode — same update loop, same
|
||
// scoring, same death — which is the only way a test tells you anything about the game.
|
||
// This is laserScene's trick: force the turn and then step the transition out in one go, because
|
||
// the cinematic is ten seconds you did not come here to watch.
|
||
function intoSplatter() {
|
||
if (G.mode === 'tennis') forceZombies();
|
||
for (let i = 0; i < 900 && G.mode !== 'splatter'; i++) update(1 / 60);
|
||
G.modal = null; G.horde = null; G.hordeWarn = 0; G.interlude = false; G.changeover = 0; G.clearing = false;
|
||
G.mgT = 0; G.gunId = null; G.beam = null; G.hp = G.hpMax = TUNE.playerHp;
|
||
zombies.length = 0; shots.length = 0; pellets.length = 0;
|
||
score.server = 'P'; setupPoint();
|
||
}
|
||
document.getElementById('scWeapons').addEventListener('click', () => {
|
||
start(null); intoSplatter(); toggleTestMode();
|
||
});
|
||
document.getElementById('scBosses').addEventListener('click', () => go('bosses'));
|
||
// THE DRILL, from the back room — the cognitive mode's feel-test. Like the two above it drops
|
||
// into a real run first (same loop, same contact rules) and then hands the court to startDrill,
|
||
// which refuses if anything else still owns it.
|
||
document.getElementById('scDrill').addEventListener('click', () => {
|
||
start(null); intoSplatter();
|
||
startDrill();
|
||
});
|
||
document.getElementById('scMatch').addEventListener('click', () => {
|
||
start(null); intoSplatter();
|
||
startMatch();
|
||
});
|
||
// THE SPRITE BENCH — the art test. Same "drop into a real run first" trick as its neighbours, so the
|
||
// body is drawn by the real renderer at the real size, on the real court, under the real palette.
|
||
// Judging a walk cycle anywhere else is judging a different picture.
|
||
document.getElementById('scBench').addEventListener('click', () => {
|
||
start(null); intoSplatter();
|
||
startBench();
|
||
});
|
||
|
||
// The list is built by ASKING bossArt which design each chapter gets, so it can never drift from
|
||
// what the run actually spawns — including the champion sitting on chapter 10 rather than 7.
|
||
const bossList = document.getElementById('bossList');
|
||
for (let ch = 1; ch <= 10; ch++) {
|
||
const art = bossArt(ch), b = document.createElement('button');
|
||
b.innerHTML = `<img src="assets/${art.walk[0]}.png?v=${SPRV}" alt="">`
|
||
+ `<b>${art.name}</b><i>CHAPTER ${ch} · GAME ${ch * TUNE.bossEvery}</i>`;
|
||
b.addEventListener('click', () => {
|
||
start(null); intoSplatter();
|
||
G.runGames = ch * TUNE.bossEvery; // the roster, the HUD and the spawn ramp all read this one
|
||
G.bossDue = 0; // ...and the schedule must not queue a SECOND fight behind it
|
||
for (const id of Object.keys(WEAPONS)) grantWeapon(id, TUNE.wCap); // full arsenal, every tin
|
||
G.weapon = 0;
|
||
startBossFight(ch);
|
||
});
|
||
bossList.appendChild(b);
|
||
}
|
||
// the three period tabs are one listener on the strip — the button carries the period in data-p
|
||
// the two tab strips are one listener each — the button carries its value in a data attribute
|
||
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, stMode);
|
||
});
|
||
document.getElementById('stModes').addEventListener('click', e => {
|
||
const b = e.target.closest('button'); if (!b) return;
|
||
stMode = b.dataset.m;
|
||
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
|
||
stats.drawStats(stPeriod, SPRV, stMode);
|
||
});
|
||
refreshSplash();
|
||
|
||
// THE BOOT GATE. render.js fires all ~230 sprite requests at import; this only watches them and
|
||
// keeps the splash's buttons out of reach until the last one has landed and both faces are ready.
|
||
// Without it a cold start could open the stats screen — which hangs <img> tags on the same files —
|
||
// or start a rally while half the sheets were still in flight, and the missing ones drew as
|
||
// whatever the fallback was. onerror counts too (render.js), so a dead network still finishes the
|
||
// bar rather than parking the player on it forever.
|
||
const splash = document.getElementById('splash'), bootFill = document.querySelector('#boot i');
|
||
splash.classList.add('busy');
|
||
const loaded = () => !splash.classList.contains('busy');
|
||
// setInterval, NOT requestAnimationFrame: rAF does not fire in a hidden tab, and a phone that
|
||
// launches the app and locks — or an install that loads in the background — would come back to a
|
||
// bar frozen at whatever it reached and a menu with no buttons on it. An interval still ticks
|
||
// (throttled to about a second), so the gate always opens.
|
||
const tick = setInterval(function watchLoad() {
|
||
const frac = LOAD.total ? LOAD.done / LOAD.total : 1;
|
||
bootFill.style.width = (frac * 100).toFixed(1) + '%';
|
||
if (frac < 1) return;
|
||
clearInterval(tick);
|
||
// fonts.ready resolves once nothing is pending — including the three faces warmed above — so the
|
||
// first canvas frame is never the system serif.
|
||
(document.fonts ? document.fonts.ready : Promise.resolve()).then(() => splash.classList.remove('busy'));
|
||
}, 100);
|
||
|
||
// 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('src/main.js', { method: 'HEAD' }) // page-relative: index.html sits at the root, the code in src/
|
||
.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 (!loaded()) return; // the boot bar hides the buttons; a key must not walk past it
|
||
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') return;
|
||
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
|
||
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
|
||
if (G.match) stats.endRun(G.match.opp - 1, score.games[0] + score.games[1], G.splatT, false, stats.MODE_MATCH);
|
||
else 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 = () => {
|
||
// ...and the classic ending taps out the same way, on its own shorter clock (G.done, no corpse)
|
||
const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
|
||
if (G.modal || !ready) return;
|
||
restartRun();
|
||
refreshSplash(); // the run just ended: the splash count has moved
|
||
intro.hidden = false;
|
||
// stats.lastRun() survives restartRun on purpose — it lives in the stats module, not on G, which
|
||
// has just been restored from PRISTINE. Consumed on read, so the prompt appears once.
|
||
const run = stats.lastRun();
|
||
if (run && run.newKillBest && run.kills > 0) askName(run);
|
||
else go('splash');
|
||
};
|
||
cv.addEventListener('pointerdown', maybeRestart);
|
||
addEventListener('keydown', maybeRestart);
|
||
|
||
// A tap on the SCOREBOARD toggles the miss-log overlay, and that is now all it does. The triple-tap
|
||
// that opened test mode has moved to the back room on the splash (see below): a cheat you can trip
|
||
// by accident mid-run is a bug, and this one sat on the one thing you tap to read the log.
|
||
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; // the scoreboard
|
||
});
|
||
|
||
|
||
|
||
// ---- 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.hordeWarn = 0; 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);
|
||
// #drill — the same door from a URL, so a phone can be sent straight into the mode
|
||
if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter(); startDrill(); }, 700);
|
||
if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700);
|
||
if (location.hash === '#cup') setTimeout(() => { start(null); intoSplatter(); startMatch(true); }, 700);
|
||
if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700);
|
||
|
||
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, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch,
|
||
stats, step: dt => update(dt), render, press: keyPressed, FALL, benchFall,
|
||
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|