Two netplay-only finds from Oliver's two-phone session. THE FRIEND'S STRIKE was silent: every other opponent is simulated locally and cracks through gameplay's shared return path, but the friend's swing runs on their phone alone and arrives as a 'shot' message that wrote the ball and nothing else. netApplyShot now plays the same sample at the same volume, pitched off the pace that was actually sent (strikeRate, read after the ball is written). Both netplay modes, not just the death match. THE REMATCH: the summary's only netplay door was THE CLUBHOUSE, so playing the same friend again meant two trips through the lobby and a fresh code. The relay cannot re-pair, so the rematch is the socket that is still open — one 'again' frame and both phones reset the run under the decisions the room already carries (same side, same mode, same court, host serves). netAgain keeps G.net across restartRun(true) and replays netFresh, netStart's own tail split out so pairing and rematch cannot drift into two courts. Applied, never echoed; a 2 s window swallows the second frame when both tap at once; a dead line refuses and falls through to the clubhouse. Also: the dev server runs with SPLAT_AUTH= (server.py always said local dev should), which the shared password had been 401-ing. audio.sfxTest is a new test seam in netTest's shape. 146 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1140 lines
67 KiB
JavaScript
1140 lines
67 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, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, LEAGUES, leagueOf, cupRoundsFor, cupFixture, cupRoundName, 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, netAgain } from './net.js';
|
||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit } 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 * as recorder from './recorder.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);
|
||
|
||
// One opaque overlay stays mounted across every menu; the court is only revealed on start.
|
||
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.
|
||
// EVERY SCREEN OPENS AT ITS TOP (2026-08-31, Oliver: scroll the stats to the bottom, swipe back,
|
||
// and the splash comes up scrolled with its buttons off screen). #intro is the one scroller the
|
||
// menus share, so its offset outlives the stage that earned it unless it is reset here — the one
|
||
// place every stage change goes through.
|
||
const go = stage => {
|
||
if (stage === 'splash') refreshSplash();
|
||
if (stage === 'stats') refreshRecorder();
|
||
if (stage === 'setup') syncSetup();
|
||
if (stage === 'train') buildTrain();
|
||
intro.dataset.stage = stage;
|
||
intro.scrollTop = 0;
|
||
dealStage(stage);
|
||
menuMusic();
|
||
};
|
||
// THE ARCADE ENTRANCE, re-armed. The title screen deals its plaque and buttons in from the left
|
||
// every time you arrive at it — off a run, off the stats, off the boot. Removing the class and
|
||
// forcing a reflow before adding it back is the whole trick: an animation whose class never left
|
||
// cannot restart, and there is no "replay" switch in CSS.
|
||
function dealStage(stage) {
|
||
// the title screen waits for the boot: while the loading state is up there is nothing to deal
|
||
// (the element is looked up here, not read off the const below: go() can fire before that line
|
||
// has run, and a temporal-dead-zone throw in the menu funnel would take every screen with it)
|
||
if (stage === 'splash' && document.getElementById('splash').classList.contains('busy')) return;
|
||
intro.classList.remove('deal');
|
||
void intro.offsetWidth;
|
||
intro.classList.add('deal');
|
||
}
|
||
|
||
// Additional solo modes. League, Gore and multiplayer have their own splash doors.
|
||
let pickedMode = remembered('mode') || MODES[0].id;
|
||
let pickedDiff = remembered('diff') || 'medium';
|
||
const modeOf = id => MODES.find(x => x.id === id);
|
||
const modeList = document.getElementById('modeList');
|
||
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
|
||
function buildTops() {
|
||
modeList.innerHTML = '';
|
||
for (const { mode } of MENU) {
|
||
const card = modeOf(mode), b = document.createElement('button');
|
||
b.className = 'mode';
|
||
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
|
||
b.innerHTML = `<span class="cb"><b>${card.name}</b></span>`;
|
||
b.addEventListener('click', () => {
|
||
pickMode(mode);
|
||
go(mode === 'train' ? 'train' : 'setup');
|
||
});
|
||
modeList.appendChild(b);
|
||
}
|
||
}
|
||
buildTops();
|
||
const difficultyList = document.getElementById('difficultyList');
|
||
for (const d of DIFFS) {
|
||
const b = document.createElement('button');
|
||
b.textContent = d.name;
|
||
b.dataset.id = d.id;
|
||
b.addEventListener('click', () => {
|
||
pickedDiff = d.id;
|
||
try { localStorage.setItem('diff', d.id); } catch {}
|
||
syncSetup();
|
||
});
|
||
difficultyList.appendChild(b);
|
||
}
|
||
|
||
// ---- 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(); go('splash');
|
||
});
|
||
// 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;
|
||
};
|
||
|
||
// 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 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];
|
||
// The editor and profile draw the same kit; drawing the hidden one is a cheap blit.
|
||
const ok = 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;
|
||
refreshLook();
|
||
|
||
// THE LEAGUE PANEL (the leagues): the cup's setup 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 these panels replace still shows for every other 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;
|
||
// ...and THE RECORD (THE SEASON): how far this league's cup has ever let you get. It is the one
|
||
// line that makes a lost attempt count for something, so it takes the second half of the sub
|
||
// whenever there is one to show.
|
||
const best = profile.cupBest(lg.id);
|
||
document.getElementById('lgSub').innerHTML = (felled >= size
|
||
? 'ALL ' + size + ' FELLED'
|
||
: felled + ' OF ' + size + ' FELLED')
|
||
+ (best ? '<br>BEST IN THE CUP: ' + cupRoundName(lg.id, best)
|
||
: '<br>THE CUP IS THE WAY UP — NEVER ENTERED');
|
||
}
|
||
|
||
// A setup contains only decisions for this match. Identity lives in the player screen.
|
||
function syncSetup() {
|
||
document.getElementById('sTitle').textContent = modeOf(pickedMode).name;
|
||
document.getElementById('sPlayerName').textContent = stats.savedName() || 'YOUR PLAYER';
|
||
document.getElementById('sPlayerKit').textContent =
|
||
(KITS.find(k => k.id === pickedKit) || KITS[0]).name;
|
||
document.getElementById('sDifficulty').hidden = pickedMode !== 'classic';
|
||
for (const b of difficultyList.children) {
|
||
b.classList.toggle('on', b.dataset.id === pickedDiff);
|
||
b.setAttribute('aria-pressed', b.dataset.id === pickedDiff);
|
||
}
|
||
const opponent = document.getElementById('sOpponent');
|
||
opponent.hidden = pickedMode !== 'train';
|
||
if (pickedMode === 'train') {
|
||
const f = matchFoe(trainRung);
|
||
opponent.querySelector('img').src = foeArt(f);
|
||
opponent.querySelector('b').textContent = f.name;
|
||
}
|
||
const climb = document.getElementById('sClimb');
|
||
climb.hidden = pickedMode !== 'zombie';
|
||
climb.textContent = `THE CLUB, BOTTOM UP · ${leagueOf(profile.prof.lg).band[1]} OPPONENTS`;
|
||
if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court;
|
||
let locked = 0;
|
||
for (const el of courtButtons) {
|
||
const open = courtUnlocked(el.dataset.id);
|
||
el.disabled = !open;
|
||
el.classList.toggle('lock', !open);
|
||
el.classList.toggle('on', open && el.dataset.id === picked);
|
||
el.setAttribute('aria-pressed', open && el.dataset.id === picked);
|
||
const sub = el.querySelector('.cb > i');
|
||
sub.textContent = open ? '' : 'LEAGUE ' + courtLeague(el.dataset.id).id;
|
||
sub.hidden = open;
|
||
(open ? courtList : lockedCourtList).appendChild(el);
|
||
if (!open) locked++;
|
||
}
|
||
document.getElementById('lockedCourts').hidden = !locked;
|
||
document.getElementById('lockedCourtLabel').textContent = `LOCKED COURTS · ${locked}`;
|
||
}
|
||
|
||
// --- 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() {
|
||
document.getElementById('pGo').textContent = profile.prof.setup ? 'SAVE PLAYER' : 'ENTER THE CLUB';
|
||
document.getElementById('pNote').hidden = !!profile.prof.setup;
|
||
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(); });
|
||
function leavePlayer(saved) {
|
||
const back = playerReturn; playerReturn = null;
|
||
if (back === 'card') { buildCard(); go('card'); }
|
||
else if (back === 'setup') go('setup');
|
||
else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); }
|
||
else go('splash');
|
||
}
|
||
document.getElementById('pBack').addEventListener('click', () => leavePlayer(false));
|
||
document.getElementById('pGo').addEventListener('click', () => {
|
||
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
|
||
try { localStorage.setItem('tour', pickedTour); } catch {}
|
||
profile.prof.setup = 1; profile.save(); profile.push();
|
||
leavePlayer(true);
|
||
});
|
||
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.
|
||
// the label is a TITLE, not a printed word (the text reduction): the legend stands once over the
|
||
// list — two bars in a fixed order need naming once, not on every row
|
||
const meter = (label, v, cls = '') =>
|
||
`<div class="meter ${cls}" title="${label}"><s><i style="width:${
|
||
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
|
||
const courtList = document.getElementById('courtList');
|
||
const lockedCourtList = document.getElementById('lockedCourtList'), courtButtons = [];
|
||
// 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 hidden></i>` // the sub only ever carries a LOCK now (syncSetup)
|
||
+ 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;
|
||
try { localStorage.setItem('court', picked); } catch {}
|
||
syncSetup();
|
||
});
|
||
courtButtons.push(b);
|
||
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 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 picker and the court show the same face
|
||
const foeArt = f => `assets/z_${f.fam}head.png?v=${SPRV}`;
|
||
|
||
// Training chooses the opponent first, then the shared setup confirms the court and starts.
|
||
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]);
|
||
// THE REVANCHE (THE SEASON): the body that ended your last cup attempt is marked and sits FIRST
|
||
// in its own right — a practice list where every rung looks the same is a shelf, and this one
|
||
// room in the game has an obvious reason to exist the moment you have just been put out.
|
||
const out = profile.prof.out;
|
||
const rung = n => {
|
||
const f = matchFoe(n), b = document.createElement('button');
|
||
b.addEventListener('click', () => { trainRung = n; go('setup'); });
|
||
return [f, b];
|
||
};
|
||
// THE REVANCHE IS A ROW, not a tile in the grid (the text reduction): it is one body out of
|
||
// eighteen and the only one with a reason, so it gets the width to say so — and says it in
|
||
// three words, because the screen it sits on is already called TRAINING.
|
||
if (out >= 1 && out <= top) {
|
||
const [f, b] = rung(out);
|
||
b.className = 'put';
|
||
b.innerHTML = `<img src="${foeArt(f)}" alt=""><span><b>${f.name}</b><i>PUT YOU OUT</i></span>`
|
||
+ '<em>></em>';
|
||
trainList.appendChild(b);
|
||
}
|
||
for (let n = 1; n <= top; n++) {
|
||
if (n === out) continue;
|
||
const [f, b] = rung(n);
|
||
// the rung is a NUMBER on a list of numbers — two digits, no "No."
|
||
b.innerHTML = `<img src="${foeArt(f)}" alt=""><b>${f.name}</b>`
|
||
+ `<i>${String(n).padStart(2, '0')}</i>`;
|
||
trainList.appendChild(b);
|
||
}
|
||
// ...and the list ENDS somewhere: the next league's band is drawn as the goal it is, not left
|
||
// to stop dead at the cap with no explanation (the handoff's "Ziel, keine Liste")
|
||
if (top < LADDER) {
|
||
const next = LEAGUES.find(l => l.band[0] > top);
|
||
const goal = document.createElement('div');
|
||
goal.className = 'goal';
|
||
if (next) goal.innerHTML = `<b>LEAGUE ${next.id}</b><i>${next.band[0]} — ${next.band[1]}</i>`;
|
||
trainList.appendChild(goal);
|
||
}
|
||
}
|
||
document.getElementById('trBack').addEventListener('click', () => goMode());
|
||
|
||
// --- 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));
|
||
})();
|
||
};
|
||
// A SHIELD CATCHES THE LIGHT (2026-08-31, Oliver: not burning — "manchmal random schimmern").
|
||
// The first cut was a chimney of embers, which read as a fire on a trophy. This is the opposite:
|
||
// Three points ON the shield: the first catches the light within a second of opening the card,
|
||
// then the long, varied cycles keep it occasional. CSS keyframes, no game loop.
|
||
const glints = won => !won ? '' : Array.from({ length: 3 }, (_, i) =>
|
||
`<em style="left:${(18 + Math.random() * 60).toFixed(0)}%;` +
|
||
`top:${(12 + Math.random() * 52).toFixed(0)}%;` +
|
||
`animation-delay:${(0.15 + i * 0.6 + Math.random() * 0.3).toFixed(2)}s;` +
|
||
`animation-duration:${(6 + Math.random() * 5).toFixed(2)}s"></em>`).join('');
|
||
|
||
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');
|
||
// the crest stands beside this line now, and the column is narrow: two short lines beat one
|
||
// that wraps three times (the league's NAME is the second, in its own colour)
|
||
el.innerHTML = `LEAGUE ${lg.id} OF ${LEAGUES.length}<i>${lg.name}</i>`;
|
||
el.style.color = lg.color;
|
||
// ...and the cap belongs to the bar under it, which already counts toward the next level
|
||
document.getElementById('cdLevel').textContent = 'LEVEL ' + pr.level;
|
||
document.getElementById('cdFrom').textContent = pr.level;
|
||
document.getElementById('cdTo').textContent = pr.need ? pr.level + 1 : 'MAX';
|
||
// 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) {
|
||
// 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.
|
||
const won = l.id < profile.prof.lg || (l.id === LEAGUES.length && profile.prof.ch);
|
||
const at = profile.cupAt(l.id);
|
||
const cell = document.createElement('div');
|
||
cell.className = 'badge' + (won ? '' : ' dark');
|
||
// THE INSCRIPTION (2026-08-31, the cup board): a trophy says when it was won. Here that is the
|
||
// LEVEL it was won at — the number the cup board breaks its ties on, in the one place where a
|
||
// figure set once and never again belongs.
|
||
// an unearned slot says nothing: the shield's own art already says which league it is
|
||
cell.innerHTML = `<img src="assets/badge_l${l.id}.png?v=${SPRV}" alt="" title="${l.name}">`
|
||
+ `<u>${won ? (at ? 'AT ' + at : 'WON') : '—'}</u>` + glints(won);
|
||
row.appendChild(cell);
|
||
}
|
||
const champ = document.createElement('div');
|
||
champ.className = 'badge' + (profile.prof.ch ? '' : ' dark');
|
||
champ.innerHTML = `<img src="assets/badge_champ.png?v=${SPRV}" alt="" title="CHAMPION OF THE DEAD CLUB">`
|
||
+ `<u>${profile.prof.ch ? 'CHAMPION' : '—'}</u>` + glints(profile.prof.ch);
|
||
row.appendChild(champ);
|
||
const rows = [['LEAGUE', profile.prof.lg], ['CUPS WON', profile.prof.lg - 1 + profile.prof.ch],
|
||
['BEST CUP', profile.cupBest(profile.prof.lg)]];
|
||
const box = document.getElementById('cdFigs1');
|
||
box.innerHTML = rows.map(([label]) => fig(label, '0')).join('');
|
||
[...box.querySelectorAll('.fig i')].forEach((cell, i) => {
|
||
countUp(v => cell.textContent = v.toLocaleString('en-GB'), rows[i][1]);
|
||
});
|
||
buildLeaguePanel();
|
||
refreshLook();
|
||
}
|
||
document.getElementById('plaque').addEventListener('click', () => { buildCard(); go('card'); });
|
||
document.getElementById('cdBack').addEventListener('click', () => go('splash'));
|
||
let playerReturn = null; // The editor returns to its caller; first-time cup entry resumes the cup.
|
||
document.getElementById('cdEdit').addEventListener('click', () => {
|
||
playerReturn = 'card'; buildPlayer(); go('player');
|
||
});
|
||
document.getElementById('sEdit').addEventListener('click', () => {
|
||
playerReturn = 'setup'; 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';
|
||
// ...and what the new league has you down for. The champion has nothing above him, so his beat
|
||
// is the title he now has to hold instead of a fixture he has to win.
|
||
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||
document.getElementById('prNext').innerHTML = champion
|
||
? `THE TITLE IS YOURS TO HOLD<u>EVERY CUP FROM HERE IS A DEFENCE</u>`
|
||
: `NEXT: ${fx.title}<u>${fx.line}</u>`;
|
||
}
|
||
// THE CUP BOARD (2026-08-31): a promotion is the row, and it posts itself. There is no name
|
||
// prompt in the way — you have had a name since the identity screen — and no personal-best gate:
|
||
// the server keeps one row per name per board and the league you just took either beats what
|
||
// stands there or does not (rank_key, server.py).
|
||
function postCup(pr) {
|
||
const took = pr.champion ? LEAGUES.length : pr.took;
|
||
if (!took || !pr.at || !stats.savedName()) return;
|
||
stats.submit(stats.savedName(), took, pr.at, stats.MODE_CUP, profile.prof.lg).catch(() => {});
|
||
}
|
||
document.getElementById('prGo').addEventListener('click', () => {
|
||
const run = promoRun; promoRun = null;
|
||
if (run && run.newKillBest && run.score > 0) askName(run);
|
||
else go('splash');
|
||
});
|
||
|
||
// Open an existing world board. Classic has no leaderboard; its fallback is Gore.
|
||
const boardFor = m => m === 'cup' ? stats.MODE_CUP
|
||
: m === 'zombie' || m === 'train' ? stats.MODE_MATCH : stats.MODE_GORE;
|
||
|
||
// The splash plaque opens the profile and its stats; the world board has its own door.
|
||
// Re-read on each arrival because the preceding run may have moved the level or league.
|
||
function refreshSplash() {
|
||
// THE PLAQUE — the meta identity, re-read whenever the splash comes back because a run has just
|
||
// moved it. One row now: crest, name and level, and the bar
|
||
// under them wearing the level it fills toward. The goal LINE is gone — the fixture button below
|
||
// says what to do, and it said it twice.
|
||
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;
|
||
document.getElementById('plTo').textContent = pr.need ? pr.level + 1 : 'MAX';
|
||
document.getElementById('plFill').style.width = Math.round(pr.frac * 100) + '%';
|
||
// THE FRONT DOOR (THE SEASON): the fixture, in the same words the summary and the promotion use
|
||
// (config.cupFixture) — minus the round count, which lives in the tournament's own modal now.
|
||
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
|
||
document.getElementById('msTitle').textContent = fx.title;
|
||
document.getElementById('msLine').textContent = fx.line;
|
||
document.getElementById('mCourt').textContent = 'COURT · ' +
|
||
(COURTS.find(c => c.id === picked) || COURTS[0]).name;
|
||
}
|
||
|
||
|
||
// `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: the CUP is the league fixture and plays the league's own court; every other
|
||
// mode — the climb included, it is an exhibition — plays your pick (among the unlocked).
|
||
selectCourt(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(); }
|
||
// the cup rolls its own draw per round (cupDraw, without replacement) — the bracket screen
|
||
// that used to settle it on-screen is gone (2026-08-29, see the foeArt note above).
|
||
if (modeId === 'cup') { intoSplatter(); startMatch(true); } // cupDraw shuffles per round
|
||
// training: the picked rung rides the match machinery with the ladder parked (beats.startMatch)
|
||
if (modeId === 'train') { intoSplatter(); startMatch(false, null, trainRung); }
|
||
}
|
||
|
||
// Career totals and detailed records live one level beneath the profile.
|
||
const openStats = () => {
|
||
stats.drawStats('all', SPRV); go('stats');
|
||
};
|
||
|
||
// A phone can record without a console. Opt in from Stats or ?record=1; export is local/share
|
||
// sheet only. Keep this off the normal splash and give active recordings one small live marker.
|
||
const recordingMeta = () => ({
|
||
build: document.querySelector('script[src*="main.js"]').getAttribute('src'),
|
||
viewport: [innerWidth, innerHeight], pixelRatio: devicePixelRatio,
|
||
touch: matchMedia('(pointer: coarse)').matches,
|
||
});
|
||
function refreshRecorder() {
|
||
const r = recorder.recordingInfo();
|
||
document.getElementById('recLabel').textContent = 'PLAYTEST RECORDING' + (r.full ? ' · FULL' : r.active ? ' · ON' : '');
|
||
document.getElementById('recStatus').textContent = r.warning || (r.full
|
||
? 'Recording full. Export it, then start a new recording.'
|
||
: (r.active ? 'Recording' : r.exists ? 'Paused' : 'Off') + ' · ' + r.points + ' points saved on this phone.');
|
||
document.getElementById('recToggle').textContent = r.active ? 'STOP RECORDING' : r.exists ? 'RESUME RECORDING' : 'RECORD PLAYTEST';
|
||
document.getElementById('recToggle').disabled = r.full;
|
||
document.getElementById('recExport').disabled = !r.events;
|
||
document.getElementById('recNew').hidden = !r.exists || r.active;
|
||
const lamp = document.getElementById('recLamp');
|
||
lamp.hidden = !r.active && !r.full;
|
||
lamp.textContent = r.full ? 'REC FULL' : 'REC';
|
||
}
|
||
recorder.onRecorderChange(refreshRecorder);
|
||
document.getElementById('recToggle').addEventListener('click', () => {
|
||
const r = recorder.recordingInfo();
|
||
if (!r.exists) recorder.beginRecording(recordingMeta());
|
||
else recorder.setRecording(!r.active, recordingMeta());
|
||
});
|
||
document.getElementById('recNew').addEventListener('click', () => recorder.beginRecording(recordingMeta()));
|
||
document.getElementById('recExport').addEventListener('click', async () => {
|
||
try {
|
||
const result = await recorder.exportRecording();
|
||
if (result === 'download') document.getElementById('recStatus').textContent = 'Download requested. Your recording is also kept on this phone.';
|
||
} catch {
|
||
document.getElementById('recStatus').textContent = 'Export could not open. Your recording is kept; please try again.';
|
||
}
|
||
});
|
||
if (new URLSearchParams(location.search).get('record') === '1') {
|
||
if (recorder.recordingInfo().exists) recorder.setRecording(true, recordingMeta());
|
||
else recorder.beginRecording(recordingMeta());
|
||
}
|
||
refreshRecorder();
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.visibilityState === 'hidden') {
|
||
recorder.recordEvent('run_end', { outcome: 'backgrounded' }); recorder.saveRecording();
|
||
}
|
||
});
|
||
addEventListener('pagehide', () => recorder.saveRecording());
|
||
const goMode = () => { buildTops(); go('mode'); };
|
||
document.getElementById('mStart').addEventListener('click', () => {
|
||
if (!profile.prof.setup) { playerReturn = 'cup'; buildPlayer(); go('player'); return; }
|
||
pickMode('cup'); start('cup');
|
||
});
|
||
document.getElementById('mOther').addEventListener('click', goMode);
|
||
document.getElementById('mVs').addEventListener('click', () => vsOpen());
|
||
document.getElementById('mGore').addEventListener('click', () => {
|
||
pickMode('gore'); start('gore');
|
||
});
|
||
document.getElementById('mCourt').addEventListener('click', () => {
|
||
pickMode('gore'); go('setup');
|
||
});
|
||
document.getElementById('cdStats').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.
|
||
// Every setup's primary button starts exactly the match shown above it.
|
||
document.getElementById('cStart').addEventListener('click', () => start());
|
||
for (const id of ['mBack', 'rkBack', 'scBack'])
|
||
document.getElementById(id).addEventListener('click', () => go('splash'));
|
||
document.getElementById('stBack').addEventListener('click', () => { buildCard(); go('card'); });
|
||
document.getElementById('sBack').addEventListener('click', () => {
|
||
if (pickedMode === 'gore') go('splash');
|
||
else if (pickedMode === 'train') go('train');
|
||
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
|
||
refreshSplash();
|
||
// the profile mirror: adopt a server copy that is ahead (a wiped phone, a second device), then
|
||
// let the plaque show whatever came back. The BOOT GATE holds the splash locked (skeleton
|
||
// plaque, no buttons, no keys) until this settles — capped at 4 s, because a dead network must
|
||
// never park the player on the title (the sprite loader's own rule).
|
||
let profileDone = false;
|
||
profile.pull().then(() => { profileDone = true; refreshSplash(); });
|
||
setTimeout(() => { profileDone = true; }, 4000);
|
||
|
||
// 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'); // belt and braces: the class ships in index.html so the very
|
||
// first paint is already the loading state — this only matters if
|
||
// that ever gets dropped from the markup
|
||
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 || !profileDone) return; // sprites AND the profile mirror (or its 4 s cap)
|
||
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'); dealStage('splash'); });
|
||
}, 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 (stage === 'splash' && !loaded()) return; // the boot gate holds the keys too
|
||
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 = e => {
|
||
// ...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;
|
||
// WHICH DOOR (THE SEASON: "das ende reicht weiter"). The summary lays its own buttons out as it
|
||
// draws — it measures its box, so only it knows where they landed — and hands the rects back
|
||
// through endHit. A tap anywhere ELSE is the old behaviour exactly: back to the club.
|
||
let door = null;
|
||
if (e && e.clientX !== undefined) {
|
||
const r = cv.getBoundingClientRect();
|
||
door = endHit((e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height);
|
||
}
|
||
// THE REMATCH is the one door that does not end the run here: the socket is still up, so net.js
|
||
// resets both phones itself and the menu never comes back. It refuses (and falls through to the
|
||
// clubhouse) if the friend has already hung up.
|
||
if (door === 'rematch' && netAgain()) 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) { postCup(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
|
||
// ...on a board that still EXISTS: classic tennis left the world board with the stats streamline
|
||
// (out of competition, by its own room's rule), so a classic best has nowhere to be written
|
||
if (run && run.newKillBest && run.score > 0 && run.mode !== stats.MODE_CLASSIC) { askName(run); return; }
|
||
// the fixture button restarts the canonical path without a detour through the menus; PLAY AGAIN
|
||
// re-deals the mode that just ended (pickedMode is what started it, cup included)
|
||
if (door === 'next') { pickMode('cup'); start('cup'); return; }
|
||
if (door === 'again') { start(pickedMode); return; }
|
||
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; } };
|