Files
splatter-tennis/src/main.js
T

1142 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, matchFoe, LADDER } from './config.js';
import { P, O, ball, score, G, setupPoint, sendBoy, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startHorde, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
import { netConnect, netHooks } from './net.js';
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } 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 { haptic } from './haptics.js';
import * as stats from './stats.js';
import * as profile from './profile.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();
}
// PORTRAIT ONLY, from 2026-08-20. The app enforces it where it can actually be enforced — the
// shell's Info.plist lists one supported orientation, so an iPhone running the store build simply
// never rotates. This line is the WEB half of the same decision and is best-effort by nature:
// Chrome/Android honours a lock once the page is installed or fullscreen, and iOS Safari has no
// Screen Orientation lock at all, so the browser build keeps doing whatever landscape does today
// (PROJECT.md OPEN 6 — landscape is untested, not a surface). Nothing downstream may assume it took.
try { screen.orientation?.lock?.('portrait')?.catch(() => {}); } catch {}
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;
// every stage change is a gesture, so it is also a safe place to (re)start the theme. The setup
// screen syncs its picker rows on entry: the identity screen writes the same picked* state, and
// rows that only ever toggle on their OWN taps would keep showing the boot-time selection.
const go = stage => { if (stage === 'setup') syncSetup(); if (stage === 'train') buildTrain(); intro.dataset.stage = stage; menuMusic(); };
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
// tennis deals its two sub-mode cards, gore just is gore. `pickedMode` stays the RUN mode id
// (classic/zombie/cup/gore) so start(), stats and the summary never learn about the grouping.
let pickedMode = remembered('mode') || MODES[0].id;
let pickedDiff = remembered('diff') || 'medium';
const modeOf = id => MODES.find(x => x.id === id);
const topOf = m => MENU.find(t => t.mode === m || (t.kids && t.kids.includes(m))) || MENU[0];
const modeList = document.getElementById('modeList');
const modeSub = document.getElementById('modeSub');
// QUESTION SCREENS CARRY NO SELECTION (Oliver: a highlighted card that also navigates is "a
// checkbox and a button combined"). Every poster is full-bright and equal — all of them are live
// answers — and the only memory shown is the LAST tag, an indicator rather than a selection. The
// select-and-confirm border lives solely on the final screen, where SERVE UP actually confirms.
const banner = (card, cls, last) => {
const b = document.createElement('button');
b.className = cls;
// 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/${card.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${card.name}</b><i>${card.sub}</i></span>`
+ (last ? '<u class="last">LAST</u>' : '')
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
+ (card.tag ? `<u class="way">${card.tag}</u>` : '');
return b;
};
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
// TAP ADVANCES. Each screen's tap both answers and moves on — the wizard's whole promise is one
// decision per screen, and a NEXT button under a single row of choices is a second tap for nothing.
// (NETPLAY is the exception and has a screen of its own — see THE LOBBY below.)
function buildSub(top) {
modeSub.innerHTML = '';
const t = top || topOf(pickedMode);
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
if (t.diffs) for (const d of DIFFS) {
const b = document.createElement('button');
b.className = 'chip';
b.innerHTML = d.name + (d.id === pickedDiff ? '<u class="last">LAST</u>' : '');
b.addEventListener('click', () => {
pickedDiff = d.id;
try { localStorage.setItem('diff', d.id); } catch {}
summary();
go('setup');
});
modeSub.appendChild(b);
}
else if (t.kids) for (const id of t.kids) {
const b = banner(modeOf(id), 'mode', id === pickedMode);
b.addEventListener('click', () => {
pickMode(id);
summary();
go('setup');
});
modeSub.appendChild(b);
}
}
// the top posters are rebuilt on every visit so the LAST tag follows what was actually played
function buildTops() {
modeList.innerHTML = '';
for (const t of MENU) {
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
b.addEventListener('click', () => {
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
// the stats screen or the summary could name, and the lobby starts the game itself
if (t.net) { vsOpen(); return; }
// a group remembers which child you were on; entering it fresh lands on its first card
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
buildSub();
summary();
go(t.diffs || t.kids ? 'modesub' : 'setup');
});
modeList.appendChild(b);
}
}
buildTops();
buildSub();
// ---- THE LOBBY. Oliver, 2026-08-15: *"multiplayer should be easier to setup — a share with link
// next to the code; entering the code is done in some plain js confirm dialog thingy; somewhere
// when hosting or also when joining the player should name themselves."* All three are one screen.
//
// WHY IT IS A MENU SCREEN AND NOT A COURT. Hosting used to drop you straight onto an empty court
// and show the code in a canvas modal — which cannot carry a SHARE button, cannot be typed into,
// and turns a mistyped code into a CONNECTION LOST card over a match that never started. The
// socket is now opened from the menu and the game stays parked behind the overlay until the room
// actually fills: `netHooks.paired` is what takes the court, one frame before netStart writes to
// it. Everything else about the protocol is untouched.
//
// THE NAME IS THE WORLD BOARD'S NAME (stats.saveName). One name per player, wherever it shows.
const vsPick = document.getElementById('vsPick'), vsWait = document.getElementById('vsWait');
const vsName = document.getElementById('vsName'), vsCode = document.getElementById('vsCode');
const vsSay = (id, text) => { document.getElementById(id).textContent = text; };
function vsOpen(code = '') {
vsName.value = stats.savedName();
vsCode.value = code;
vsPick.hidden = false; vsWait.hidden = true;
vsSay('vsErr', '');
go('vs');
}
// join === null hosts; `death` is the host's one decision and rides the room to both phones
function vsGo(join, death = false) {
stats.saveName(vsName.value); // whatever is in the field is who the other phone sees
const code = join ? stats.tidyName(vsCode.value).replace(/[^A-Z0-9]/g, '') : null;
if (join && code.length < 4) { vsSay('vsErr', 'THE CODE IS FOUR LETTERS'); return; }
if (!startNetVs()) return; // the court-ownership contract, same as every other mode
netConnect(code, death, picked); // the host's court rides the room (server.py)
vsPick.hidden = true; vsWait.hidden = false;
vsSay('vsWHdr', join ? 'JOINING' : 'ROOM CODE');
vsSay('vsBig', join ? code : '····');
vsSay('vsMsg', join ? 'KNOCKING ON THEIR DOOR' : 'WAITING FOR THEM TO JOIN');
document.getElementById('vsShare').hidden = !!join; // nothing to share on the way in
}
document.getElementById('vsHostC').addEventListener('click', () => vsGo(null));
document.getElementById('vsHostD').addEventListener('click', () => vsGo(null, true));
document.getElementById('vsJoin').addEventListener('click', () => vsGo(true));
vsCode.addEventListener('keydown', e => { if (e.key === 'Enter') vsGo(true); });
// SHARE, then COPY. navigator.share is the phone's own sheet (WhatsApp, Signal, wherever the
// friend actually is) and is exactly the "easier setup" — the clipboard is the desktop fallback,
// and the code stays on screen for the two of them in the same room reading it aloud.
// THE MESSAGE IS THE CODE, and deliberately carries no link. A link only opens the INSTALLED app
// on Android (the manifest's scope covers the site, so its WebAPK claims these URLs); an iOS
// home-screen app can claim nothing, so on an iPhone it always opened Safari instead — a second
// copy of the game, with its own storage and none of the name they picked in the app. Until this
// is a real app with universal links, four letters are the invitation. (`#net-ABCD` still works as
// a door — it is what a deep link will carry the day there is one — nothing just sends it now.)
document.getElementById('vsShare').addEventListener('click', async () => {
const text = 'JOIN ME IN SPLATTER TENNIS — THE CODE IS ' + (G.net && G.net.code || '');
try {
if (navigator.share) await navigator.share({ title: 'SPLATTER TENNIS', text });
else { await navigator.clipboard.writeText(text); vsSay('vsMsg', 'COPIED — SEND IT TO THEM'); }
} catch { /* a cancelled share sheet is not an error */ }
});
const vsQuit = () => { endNetVs(); vsOpen(); }; // a room you walked out of must give its socket back
document.getElementById('vsCancel').addEventListener('click', vsQuit);
document.getElementById('vsBack').addEventListener('click', () => {
if (!vsWait.hidden) return vsQuit(); // waiting: the chevron cancels rather than leaves
endNetVs(); goMode();
});
// the three hooks net.js calls back through — all of them no-ops once the court has been taken
netHooks.paired = () => start('classic'); // start() refuses when the menu is already down (URL entry)
netHooks.room = code => { vsSay('vsBig', code); vsSay('vsMsg', 'WAITING FOR THEM TO JOIN'); };
netHooks.lost = text => {
if (!menuUp()) return false; // mid-match: the court's own card says it
endNetVs();
vsPick.hidden = false; vsWait.hidden = true;
vsSay('vsErr', text);
return true;
};
// --- 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.dataset.id = t.id; // syncSetup finds the row's buttons by id
// just the word now — the live player beside it is the portrait, and the tile art was a SECOND
// drawing of the same man that no colour on this screen could reach
b.textContent = t.name.replace(' SINGLES', '');
b.addEventListener('click', () => {
pickedTour = t.id;
for (const el of tourList.children) el.classList.toggle('on', el === b);
refreshLook();
summary();
});
tourList.appendChild(b);
}
// THE LIVE PLAYER, redrawn on every tap of a tour, a kit or a skin. It draws the REAL sprite through
// the REAL tint (drawLook), so there is no second recolour to keep in step with the first — the only
// way this can show a wrong colour is if the court would show it too.
// The sprite sheets load asynchronously and the menu deliberately parks the game loop, so the first
// draw can land before the art does: retry on a slow poll that stops itself the moment it succeeds.
const lookCv = document.getElementById('look'), pLookCv = document.getElementById('pLook'),
cdLookCv = document.getElementById('cdLook');
let lookPoll = 0;
function refreshLook() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const k = KITS.find(e => e.id === pickedKit) || KITS[0];
const s = SKINS.find(e => e.id === pickedSkin) || SKINS[0];
// both live players — the setup screen's and the identity screen's — are the same man and must
// move together; drawing to whichever is hidden is a no-op-cheap blit
const ok = drawLook(lookCv, t, k, s) & drawLook(pLookCv, t, k, s) & drawLook(cdLookCv, t, k, s);
clearTimeout(lookPoll);
if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly
}
// --- KIT and SKIN swatches, built from the tables. Same rule as the tour tiles: tapping selects and
// nothing more, SERVE UP is what commits. Both rows are one builder — they differ only in which
// table they read and which localStorage key they remember, and a second copy of this loop to say
// "skin" instead of "kit" would be the same eleven lines twice.
const swatches = (rowId, nameId, table, keyName, get, set) => {
const row = document.getElementById(rowId), label = document.getElementById(nameId);
for (const e of table) {
const b = document.createElement('button');
b.className = e.id === get() ? 'on' : '';
b.dataset.id = e.id; // syncSetup finds the row's buttons by id
b.style.background = e.sw;
b.title = e.name; // desktop hover; the h3 label is what a phone reads
b.setAttribute('aria-label', e.name);
b.addEventListener('click', () => {
set(e.id);
try { localStorage.setItem(keyName, e.id); } catch {}
for (const el of row.children) el.classList.toggle('on', el === b);
label.textContent = e.name;
refreshLook();
});
row.appendChild(b);
}
label.textContent = (table.find(e => e.id === get()) || table[0]).name;
};
let pickedKit = remembered('kit') || KITS[0].id;
let pickedSkin = remembered('skin') || SKINS[0].id;
swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
// THE LEAGUE PANEL (the leagues): the setup's league-mode face — your league writ large and the
// band's roster with your all-time felled counts (stats' per-foe tallies, key 'L'+index). The
// identity block this replaces still shows for every non-league mode; the card owns it anyway.
function buildLeaguePanel() {
const lg = leagueOf(profile.prof.lg);
const badge = document.getElementById('lgBadge');
badge.src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
const name = document.getElementById('lgName');
name.textContent = `LEAGUE ${lg.id} — ${lg.name}`;
name.style.color = lg.color;
const z = stats.read('all', undefined, stats.MODE_MATCH).z;
const foes = document.getElementById('lgFoes');
foes.innerHTML = '';
let felled = 0;
for (let n = lg.band[0]; n <= lg.band[1]; n++) {
const f = matchFoe(n), k = z[f.key] || 0;
if (k) felled++;
const d = document.createElement('div');
d.className = 'lgFoe' + (k ? '' : ' un') + (n === lg.band[1] ? ' fin' : '');
d.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.short}</b><i>${k ? '×' + k : '—'}</i>`;
foes.appendChild(d);
}
const size = lg.band[1] - lg.band[0] + 1;
document.getElementById('lgSub').textContent = felled >= size
? 'ALL ' + size + ' FELLED — TAKE THE CUP'
: felled + ' OF ' + size + ' FELLED · THE CUP IS THE WAY UP';
}
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen).
// Selection state lives in picked*; the rows are just its picture and must be repainted on entry.
// On the LEAGUE MODES the player block steps aside for the league panel (Oliver: the identity is
// on the card now — the setup should show the league and who of it you have already beaten).
function syncSetup() {
const leagueMode = pickedMode === 'zombie' || pickedMode === 'cup';
document.getElementById('who').style.display = leagueMode ? 'none' : 'flex';
document.getElementById('lgPanel').style.display = leagueMode ? 'block' : 'none';
document.getElementById('s01').textContent = leagueMode ? '01 · YOUR LEAGUE' : '01 · PLAYER';
if (leagueMode) buildLeaguePanel();
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
for (const el of courtList.children) {
const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id);
const open = courtUnlocked(el.dataset.id);
el.classList.toggle('lock', !open);
el.classList.toggle('on', open && el.dataset.id === picked);
// the sub line carries the lock and names the price — the tile stays visible, as a goal should
el.querySelector('i').textContent = open ? c.sub : 'LOCKED — REACH ' + l.name;
}
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
['skinRow', 'skinName', SKINS, pickedSkin]]) {
for (const el of document.getElementById(rowId).children)
el.classList.toggle('on', el.dataset.id === cur);
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
}
refreshLook(); summary();
}
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
// localStorage keys, stats' name key), so the two screens cannot disagree — which is why the rows
// are REBUILT on every visit: their .on states have to reflect taps made on the other screen.
const pName = document.getElementById('pName');
function buildPlayer() {
pName.value = stats.savedName() || profile.zombieName();
const tRow = document.getElementById('pTours');
tRow.innerHTML = '';
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.textContent = t.name.replace(' SINGLES', '');
b.addEventListener('click', () => {
pickedTour = t.id;
try { localStorage.setItem('tour', t.id); } catch {}
for (const el of tRow.children) el.classList.toggle('on', el === b);
refreshLook();
});
tRow.appendChild(b);
}
document.getElementById('pKitRow').innerHTML = '';
document.getElementById('pSkinRow').innerHTML = '';
swatches('pKitRow', 'pKitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
swatches('pSkinRow', 'pSkinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
}
document.getElementById('pDice').addEventListener('click', () => { pName.value = profile.zombieName(); });
document.getElementById('pBack').addEventListener('click', () => {
if (playerReturn === 'card') { playerReturn = null; buildCard(); go('card'); return; }
go('splash'); // setup stays unset — it re-asks
});
document.getElementById('pGo').addEventListener('click', () => {
// an emptied field is not a refusal to have a name — the die decides, like the prefill did
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
try { localStorage.setItem('tour', pickedTour); } catch {}
profile.prof.setup = 1; profile.save(); profile.push();
if (playerReturn === 'card') { playerReturn = null; buildCard(); go('card'); return; }
buildTops(); go('mode');
});
pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pGo').click(); });
// --- 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');
// THE LEAGUES lock the courts: a court belongs to its league and unlocks for EVERY mode the
// moment you reach that league (settled: the unlock is the reward formula). A court no league
// names stays free forever.
const courtLeague = id => LEAGUES.find(l => l.court === id);
const courtUnlocked = id => { const l = courtLeague(id); return !l || l.id <= profile.prof.lg; };
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.dataset.id = c.id;
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', () => {
if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it
picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b);
summary();
});
courtList.appendChild(b);
}
// a remembered pick that is locked for THIS profile (fresh install, adopted mirror) falls back
// to the league's own court rather than serving up a court the player has not earned
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
// ---- THE DRAW (Oliver, 2026-08-15: "when starting the tournament there should be a view of the
// tournament hierarchy but only the finale is settled — the game against the champion... random
// browsing through the roster in the individual spots which begins fast, gets slower and then
// settles on a random character, so that every tournament is new").
//
// THE SCREEN IS THE DRAW, NOT A TEASER. `cupPicks` is what walks on: startMatch takes it and
// cupDraw reads it instead of rolling its own, so the face you watched land in ROUND ONE is the
// body across the net. A screen that showed a different draw from the one being played would be
// worse than no screen — it would teach the player that the ceremony is a lie.
//
// The roll is one recursive setTimeout per row whose gap grows with p^2 — fast, then visibly
// slowing, then stopped. No rAF: the menu deliberately parks the game loop (see menuUp), and three
// staggered timers are the whole animation.
const cupRows = document.getElementById('cupRows'), cupGo = document.getElementById('cupGo');
let cupPicks = null, cupSpins = [];
// the portrait: every family's own head crop (assets/_heads.py), the club pro's included — the
// same file the in-play walk-on card uses, so the draw and the court show the same face
const foeArt = f => `assets/z_${f.fam}head.png?v=${SPRV}`;
function buildBracket() {
for (const t of cupSpins) clearTimeout(t);
cupSpins = [];
const R = cupRoundsFor(profile.prof.lg);
// WITHOUT REPLACEMENT, like cupDraw: a league band is 3-4 rungs, so the non-final rounds are a
// shuffle of the sub-band — every tournament a new order, no body dealt twice, the final settled
cupPicks = [];
const used = new Set();
for (const r of R) {
const pool = r.band.filter(x => !used.has(x));
const pick = (pool.length ? pool : r.band)[Math.floor(Math.random() * (pool.length ? pool : r.band).length)];
used.add(pick); cupPicks.push(pick);
}
// the header counts the league's own rounds (a 3-rung league plays three)
document.querySelector('#bracket h3').firstChild.nodeValue =
R.length === 4 ? 'FOUR ROUNDS' : 'THREE ROUNDS';
cupRows.innerHTML = '';
cupGo.disabled = true;
document.getElementById('brState').textContent = 'DRAWING…';
const lg = leagueOf(profile.prof.lg);
document.getElementById('brSum').innerHTML =
`<span>ONLY THE FINAL WAS EVER SETTLED</span><span>${lg.name} · ${
(COURTS.find(c => c.id === lg.court) || COURTS[0]).name}</span>`;
R.forEach((r, i) => {
const fixed = r.band.length === 1; // the final: one rung, nothing to roll
const row = document.createElement('div');
row.className = 'cupRow' + (fixed ? ' fin set' : '');
row.innerHTML = `<u>${r.name}</u><img alt=""><b></b><i>${
r.need === 1 ? 'ONE GAME' : r.need + ' GAMES'}</i>`;
cupRows.appendChild(row);
const img = row.querySelector('img'), nm = row.querySelector('b');
const show = n => { const f = matchFoe(n); img.src = foeArt(f); nm.textContent = f.name; };
if (fixed) return show(cupPicks[i]);
const t0 = performance.now(), len = 1500 + i * 550; // 1.5 / 2.05 / 2.6 s — they land in order
(function roll() {
const p = Math.min(1, (performance.now() - t0) / len);
if (p >= 1) {
show(cupPicks[i]);
row.classList.add('set');
if (cupRows.querySelectorAll('.set').length === R.length) {
cupGo.disabled = false;
document.getElementById('brState').textContent = 'THE DRAW IS MADE';
}
return;
}
show(r.band[Math.floor(Math.random() * r.band.length)]);
cupSpins.push(setTimeout(roll, 45 + 330 * p * p));
})();
});
}
document.getElementById('brBack').addEventListener('click', () => {
for (const t of cupSpins) clearTimeout(t); // a screen you walked out of must stop spinning
cupSpins = [];
go('setup');
});
document.getElementById('cupGo').addEventListener('click', () => start('cup'));
// ---- THE TRAINING PICKER (Oliver, 2026-08-28: "ein Trainingsbereich — Auswahl des Gegners, ein
// Spiel, tap to replay, bis man keine Lust mehr hat"). The whole ladder face-up, one tile per rung,
// wearing the same z_*head crop the draw and the walk-on wear. Tap = start — the wizard's own
// tap-advances rule; the SERVE UP button routed here instead of starting (see cStart below).
let trainRung = 1;
const trainList = document.getElementById('trainList');
// rebuilt on entry (go('train')): you can only train who the club has let you meet — every rung
// up to the top of YOUR league's band. LADDER stays the cap for a maxed profile.
function buildTrain() {
trainList.innerHTML = '';
const top = Math.min(LADDER, leagueOf(profile.prof.lg).band[1]);
for (let n = 1; n <= top; n++) {
const f = matchFoe(n), b = document.createElement('button');
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b><i>RUNG ${n}</i>`;
b.addEventListener('click', () => { trainRung = n; start('train'); });
trainList.appendChild(b);
}
}
document.getElementById('trBack').addEventListener('click', () => go('setup'));
// --- THE CHARACTER CARD (the leagues, phase 3). The splash's level strip is the door. The
// honours row is a COLLECTION: every slot always visible, earned badges bright, the rest dark —
// the empty slots are the incentive. The ledger reads the same stats module the BODY COUNT does.
const fig = (label, val) => `<div class="fig"><b>${label}</b><i>${val}</i></div>`;
// THE CARD COUNTS UP (Oliver): bars sweep in and every number climbs fast on entry. setTimeout,
// not rAF — the menu parks the game loop (the bracket's own rule). Timers are cancelled on the
// next build so a re-entry never races itself.
let cardTimers = [];
const countUp = (write, target, dur = 700) => {
const t0 = performance.now();
(function tick() {
const p = Math.min(1, (performance.now() - t0) / dur);
write(Math.round(target * (1 - Math.pow(1 - p, 3)))); // easeOutCubic: fast, then settles
if (p < 1) cardTimers.push(setTimeout(tick, 30));
})();
};
function buildCard() {
for (const t of cardTimers) clearTimeout(t);
cardTimers = [];
const lg = leagueOf(profile.prof.lg), pr = profile.progress();
document.getElementById('cdName').textContent = stats.savedName() || 'THE WALK-ON';
document.getElementById('cdBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
const el = document.getElementById('cdLeague');
el.textContent = `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`;
el.style.color = lg.color;
document.getElementById('cdLevel').textContent =
'LEVEL ' + pr.level + ' OF ' + profile.LEVEL_CAP;
// both bars sweep from empty to their mark (the 0.7s eased transition in the CSS)
for (const [id, w] of [['cdFill', Math.round(pr.frac * 100)],
['cdCareerFill', Math.min(100, Math.round(profile.prof.xp / profile.xpFor(profile.LEVEL_CAP) * 100))]]) {
const bar = document.getElementById(id);
bar.style.transition = 'none'; bar.style.width = '0%';
void bar.offsetWidth; // commit the empty bar before the sweep
bar.style.transition = '';
cardTimers.push(setTimeout(() => { bar.style.width = w + '%'; }, 40));
}
const xpTail = pr.need
? ' · ' + (pr.need - pr.into).toLocaleString('en-GB') + ' TO LEVEL ' + (pr.level + 1)
: ' · THE CAP';
const cdXp = document.getElementById('cdXp');
countUp(v => cdXp.textContent = v.toLocaleString('en-GB') + ' XP' + xpTail, profile.prof.xp);
const row = document.getElementById('cdBadges');
row.innerHTML = '';
for (const l of LEAGUES) {
const img = document.createElement('img');
img.src = `assets/badge_l${l.id}.png?v=${SPRV}`;
img.title = l.name;
// a league's badge is EARNED by finishing it — winning its cup. Being past it means exactly
// that; league five has no "past", so its shield lights with the champion's crown.
img.className = l.id < profile.prof.lg || (l.id === LEAGUES.length && profile.prof.ch) ? '' : 'dark';
row.appendChild(img);
}
const champ = document.createElement('img');
champ.src = `assets/badge_champ.png?v=${SPRV}`;
champ.title = 'CHAMPION OF THE DEAD CLUB';
champ.className = profile.prof.ch ? '' : 'dark';
row.appendChild(champ);
const g = stats.read('all', undefined, stats.MODE_GORE),
z = stats.read('all', undefined, stats.MODE_MATCH),
c = stats.read('all', undefined, stats.MODE_CLASSIC);
// the ledger climbs: rows are [box id, [label, target, format]...]; every value starts at 0 and
// counts up through the same easing the bars sweep with
for (const [boxId, rows] of [
['cdFigs1', [['KILLS', g.kills + z.kills + c.kills],
['MULTI-KILLS', g.multi + z.multi + c.multi],
['DEATHS', g.deaths + z.deaths + c.deaths]]],
['cdFigs2', [['BEST RUN', g.longest, v => v + ' G'],
['MOST FELLED', z.longest],
['RUNS', g.matches + z.matches + c.matches]]]]) {
const box = document.getElementById(boxId);
box.innerHTML = rows.map(([label]) => fig(label, '0')).join('');
[...box.querySelectorAll('.fig i')].forEach((cell, i) => {
const fmt = rows[i][2] || (v => v.toLocaleString('en-GB'));
countUp(v => cell.textContent = fmt(v), rows[i][1]);
});
}
refreshLook();
}
document.getElementById('plaque').addEventListener('click', () => { buildCard(); go('card'); });
document.getElementById('cdBack').addEventListener('click', () => go('splash'));
let playerReturn = null; // EDIT PLAYER borrows the identity screen and comes back to the card
document.getElementById('cdEdit').addEventListener('click', () => {
playerReturn = 'card'; buildPlayer(); go('player');
});
// --- THE PROMOTION CEREMONY (the leagues). beats.cupWon parks it in the profile module; the tap
// off the cup summary lands here (maybeRestart) before the title screen. The court tile IS the
// reward on screen; the name prompt, if the run also earned one, queues behind the ceremony.
let promoRun = null;
function buildPromo(pr, run) {
promoRun = run || null;
const lg = leagueOf(profile.prof.lg);
const c = COURTS.find(x => x.id === lg.court) || COURTS[0];
const champion = !!pr.champion;
document.getElementById('prTag').textContent = champion ? 'THE FINAL CUP IS YOURS' : 'THE CUP IS YOURS';
document.getElementById('prTitle').textContent = champion ? 'CHAMPION.' : 'PROMOTED.';
document.getElementById('prLeague').textContent = champion
? 'THE DEAD CLUB HAS NOBODY LEFT'
: `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`;
document.getElementById('prCourt').src = `assets/${c.tile}.png?v=${SPRV}`;
document.getElementById('prCourtName').textContent = champion
? 'THE HONOURS BOARD REMEMBERS YOUR NAME'
: `NEW COURT UNLOCKED — ${c.name} · OPEN IN EVERY MODE`;
document.getElementById('prGo').textContent = champion ? 'TAKE A BOW' : 'WALK ON';
}
document.getElementById('prGo').addEventListener('click', () => {
const run = promoRun; promoRun = null;
if (run && run.newKillBest && run.score > 0) askName(run);
else go('splash');
});
// Which RECORD BOOK a run mode writes to. The four run modes share three books — the arcade ladder
// and the tournament are both zombie tennis and are counted together (see the record-shape note in
// stats.endRun). Lived inline in three places before the world board grew tabs of its own.
const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MODE_MATCH
: m === 'classic' ? stats.MODE_CLASSIC : stats.MODE_GORE;
// 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 lg = leagueOf(profile.prof.lg);
const leagueMode = pickedMode === 'zombie' || pickedMode === 'cup';
const c = COURTS.find(x => x.id === (leagueMode ? lg.court : 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. The cup banks through endRun as MODE_MATCH
// (see cupWon), so its summary reads the same book it writes.
const best = stats.read('all', undefined, boardFor(m.id)).longest;
const unit = m.id === 'zombie' || m.id === 'cup' ? ' FELLED'
: best === 1 ? ' GAME' : ' GAMES';
const name = m.id === 'classic'
? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name;
document.getElementById('sSum').innerHTML =
`<span>${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${leagueMode
? ' · LEAGUE ' + lg.id : ''}</span>`
// training banks nothing, so there is no mark to beat and the footer must not invent one
+ `<span>${m.id === 'train' ? 'NOTHING AT STAKE — JUST REPS'
: 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';
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
// moved it (same rule as the kill count above). The goal line is the standing answer to "what
// do I do to advance": win the CURRENT league's cup, become champion, or rest on the crown.
const pr = profile.progress(), lg = leagueOf(profile.prof.lg);
document.getElementById('plBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
document.getElementById('plName').textContent = stats.savedName() || 'YOUR PLAYER';
document.getElementById('plLv').textContent = 'LEVEL ' + pr.level + ' · LEAGUE ' + lg.id;
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
document.getElementById('plGoal').textContent = profile.prof.ch
? 'CHAMPION OF THE DEAD CLUB — DEFEND THE NAME'
: profile.prof.lg >= LEAGUES.length
? 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP — BECOME CHAMPION'
: 'WIN THE ' + lg.name.replace('THE ', '') + ' CUP TO ADVANCE';
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);
selectKit(pickedKit); selectSkin(pickedSkin);
// THE LEAGUES: a league match is played on the league's own court — the pick stays yours in
// classic, gore, netplay and training (among the unlocked courts).
selectCourt(modeId === 'zombie' || modeId === 'cup' ? leagueOf(profile.prof.lg).court : 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.
quitChip.hidden = false; // the door out rides every run (see the quit block below)
G.classic = modeId === 'classic';
// the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose
G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null;
if (modeId === 'gore') startGore(); // no tennis prologue: the mode you picked is the mode you get
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
// ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still
// means "roll it yourself", which is what the mode did before the screen existed.
if (modeId === 'cup') { intoSplatter(); startMatch(true, cupPicks); }
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
}
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 = boardFor(pickedMode);
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
};
// entering a question screen rebuilds it, so the LAST tag always names the run actually played —
// and the FIRST visit ever detours through the identity screen (the leagues): the club wants a
// name before it deals a match. ENTER THE CLUB lands on the mode screen, so the detour costs the
// question it interrupted nothing.
const goMode = () => {
if (!profile.prof.setup) { buildPlayer(); go('player'); return; }
buildTops(); go('mode');
};
document.getElementById('mStart').addEventListener('click', goMode);
document.getElementById('mStats').addEventListener('click', openStats);
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
// whose names you came to look at
let rkMode = stats.MODE_GORE;
const openRanks = () => {
rkMode = boardFor(pickedMode);
for (const el of document.getElementById('rkModes').children) el.classList.toggle('on', el.dataset.m === rkMode);
go('ranks'); stats.drawRanks(null, rkMode);
};
document.getElementById('mRanks').addEventListener('click', openRanks);
document.getElementById('rkModes').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
rkMode = b.dataset.m;
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
stats.drawRanks(null, rkMode);
});
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best on ITS
// OWN board (every mode has one now), 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;
const B = stats.BOARDS[run.mode] || stats.BOARDS[stats.MODE_GORE];
// the prompt names the BOARD's number, not the gore mode's — a tournament run that said
// "5 KILLS" would be describing something nobody was playing for
document.getElementById('nbScore').innerHTML =
`<b>${run.score.toLocaleString('en-GB')}</b> ${B.unit} · <b>${run.sub}</b> ${B.subUnit}`;
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.score, nbRun.sub, nbRun.mode, profile.prof.lg);
rkMode = nbRun.mode; // ...and the board that opens is the one you just wrote your name on
for (const el of document.getElementById('rkModes').children)
el.classList.toggle('on', el.dataset.m === rkMode);
nbRun = null;
go('ranks');
stats.drawRanks(r.scores, rkMode); // 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.
// SERVE UP starts the run — except in the cup, where the draw has to be made first. That screen
// carries its own start button, so this stays the one place a run begins from the setup screen.
document.getElementById('cStart').addEventListener('click', () => {
if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; }
if (pickedMode === 'train') { go('train'); return; } // the picker starts it — tap advances
start();
});
for (const id of ['mBack', 'stBack', 'rkBack', 'scBack'])
document.getElementById(id).addEventListener('click', () => go('splash'));
// the wizard walks back the way it came: who-and-where returns to the mode's own second step when
// it has one, the second step returns to the posters
document.getElementById('subBack').addEventListener('click', goMode);
document.getElementById('sBack').addEventListener('click', () => {
const t = topOf(pickedMode);
if (t.diffs || t.kids) { buildSub(); go('modesub'); }
else goMode();
});
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
// SWIPE TO GO BACK — RIGHTWARD, the way iOS's own pop gesture goes (Oliver, 2026-08-20: *"that's
// the iOS way"*). Every screen but the splash wears exactly one chevron in its header (.hdr in
// index.html) and that button ALREADY knows where back is for its own screen — the wizard's shape
// lives in the handlers above and nowhere else. So the gesture finds the one visible chevron and
// clicks it, rather than keeping a second map that would drift the first time a stage is added.
// A screen with no chevron (splash, newbest) has nowhere to go and the swipe is simply ignored.
// Touch only: a trackpad two-finger scroll is not a swipe and must not navigate.
const SWIPE_MIN = 60; // px of travel before it counts — a tap that slides is not a swipe
let swipe = null;
intro.addEventListener('touchstart', e => {
swipe = e.touches.length === 1 ? { x: e.touches[0].clientX, y: e.touches[0].clientY } : null;
}, { passive: true });
intro.addEventListener('touchend', e => {
const from = swipe; swipe = null;
if (!from || e.touches.length) return;
const t = e.changedTouches[0];
const dx = t.clientX - from.x, dy = t.clientY - from.y;
// rightward, and clearly horizontal: the stats and board screens scroll vertically under the finger
if (dx < SWIPE_MIN || Math.abs(dy) > Math.abs(dx) * 0.7) return;
const back = [...intro.querySelectorAll('.chev')].find(b => b.offsetParent); // the one on screen
if (!back) return;
// NOT passive, and this is why: a swipe that starts and ends inside one of the big poster cards
// still produces that card's click, which would answer the screen forward in the same frame the
// chevron walked it back. preventDefault on touchend cancels the compatibility mouse events, so
// the gesture means one thing only.
e.preventDefault();
haptic('select');
back.click();
});
// ---- 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() {
startGore(); // the direct door. It used to force the turn and then step the whole 8-second
// cinematic out in 900 hidden updates — every sound it makes fired in one frame.
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();
}
netHooks.intoSplatter = intoSplatter; // netplay's death match enters splatter through the same hack
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();
});
// (netplay has no back-room button: the VS A FRIEND poster on the mode screen IS the door, and
// #net / #net-CODE below covers sending a phone straight in from a URL)
// 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 profile mirror: adopt a server copy that is ahead (a wiped phone, a second device), then
// let the strip show whatever came back. Fire-and-forget — the menu never waits on the network.
profile.pull().then(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', e => { menuMusic(); if (e.target.closest('button')) haptic('select'); });
// 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 === ' ') goMode();
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();
// through the BUTTON, not start() — the cup's draw screen sits between the two and a keyboard
// must not be the one way to walk past it
else if (e.key === 'Enter' || e.key === ' ') document.getElementById('cStart').click();
});
// 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', () => {
profile.push(); // mirror whatever the run has earned — keepalive lets it survive the unload
if (G.mode !== 'splatter') return;
if (G.drill) return; // the drill banks nothing — its kills leak into the gore
// bag otherwise (killPlayer's drill guard covers death,
// not the tab-close path)
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
// ...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.cup ? G.match.cup.round : (G.match.felled ?? Math.max(0, 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();
profile.push(); // the run's xp is banked — mirror it while the moment is quiet
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;
quitChip.hidden = true; // the door out goes with the run it belonged to
// 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();
// THE PROMOTION plays first (the leagues): the ceremony the cup win parked, then the name
// prompt it may also have earned (prGo hands the run on), then the title.
const promo = profile.takePromo();
if (promo) { buildPromo(promo, run); go('promo'); return; }
// ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here
// silently barred two of the three modes from ever reaching the prompt
if (run && run.newKillBest && run.score > 0) askName(run);
else go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
// THE DOOR OUT (Oliver: every tennis screen lacked an exit — force-closing the app was the only
// way back). The chip shows for the whole run; tapping it (or Escape on desktop) freezes the
// world and asks. Quitting is the death path minus the death: restartRun resets everything and
// nothing is banked — an abandoned run never happened, statistically.
const quitChip = document.getElementById('quit'), qAsk = document.getElementById('qAsk');
document.getElementById('qChip').addEventListener('click', () => { paused = true; qAsk.hidden = false; });
document.getElementById('qNo').addEventListener('click', () => { qAsk.hidden = true; paused = false; });
document.getElementById('qYes').addEventListener('click', () => {
qAsk.hidden = true; paused = false; quitChip.hidden = true;
restartRun();
profile.push(); // a quit banks no run, but the xp already awarded is real — mirror it
refreshSplash();
intro.hidden = false;
go('splash');
});
addEventListener('keydown', e => {
if (e.key !== 'Escape' || !intro.hidden || quitChip.hidden) return;
qAsk.hidden = !qAsk.hidden;
paused = !qAsk.hidden;
});
// 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);
// #net opens the lobby, #net-ABCD opens it with a friend's code already in the field — that link
// IS what the SHARE button sends, so the invited phone lands one tap from the court (the tap is
// not ceremony: it is where they see, and can change, the name the other phone will call them).
if (location.hash.startsWith('#net')) setTimeout(() => vsOpen(location.hash.slice(5).toUpperCase()), 700);
requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, keyPressed, SPR, 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,
start, intoSplatter, startHorde, sendBoy, // the two run doors + the wave: the promo rig (tools/) stages every clip through them
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };