// 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 } 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 './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. // 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 === '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'); } // --- 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. // `opts.line` is the ONE second line the wall still carries: the fixture, under the hero's name. // The corner chips are gone (2026-08-31, Oliver: "sinnlos") — LAST told you what you played last // time, which the wall does not need to remember for you, and 18 RUNGS was a number about a mode // on a screen for choosing between modes. const banner = (card, cls, opts = {}) => { const b = document.createElement('button'); b.className = cls; // JUST THE ART. The scrim used to ride in this background stack as a hard linear-gradient; the // caption sits on a veil now (index.html, button.mode::after) that rises out of the bottom edge, // and stacking the old gradient under it only doubled the darkening. b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`; // THE POSTER IS ITS NAME (2026-08-31, the text reduction). The mode's one-line rule used to sit // under every name on the wall — sixty words to make one choice. The rules live in the modal // that opens with the mode; MODES.sub is still their source, it just no longer prints here. b.innerHTML = `${card.name}${opts.line ? `${opts.line}` : ''}` // the standing answer to "how do I advance", worn by the poster it is true of (config MODES) + (card.tag ? `${card.tag}` : ''); 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 || ''); // THE DIFFICULTY IS A COUNT, not a paragraph (2026-08-31, the text reduction): the name, and // three pips of which as many are lit as the chip is hard. Nothing else — the CPU explains // itself in the first rally, and it always did. if (t.diffs) DIFFS.forEach((d, i) => { const b = document.createElement('button'); b.className = 'chip' + (i === DIFFS.length - 1 ? ' hot' : ''); // no LAST chip here either (Oliver): a question screen carries no memory of its own answer — // the wizard's own rule, applied to the last screen that was still breaking it b.innerHTML = `${d.name}` + [0, 1, 2].map(k => ``).join('') + ''; b.addEventListener('click', () => { pickedDiff = d.id; try { localStorage.setItem('diff', d.id); } catch {} 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 = ''; let shelf = modeList; // posters land in the section they belong to for (const t of MENU) { // A GROUP ROW OPENS A SECTION (2026-08-31, Oliver: the headings "sind sehr klein, vielleicht // können wir daraus richtige sektionen machen"). The posters go INSIDE it — a heading that // merely floats above a stack is a label, and the wall needs three rooms, not three labels. if (t.group) { shelf = document.createElement('section'); shelf.className = 'mgroup ' + (t.layout || ''); shelf.innerHTML = `
${t.group}
`; modeList.appendChild(shelf); shelf = shelf.querySelector('.shelf'); // the posters live in the row, not the section continue; } const fx = cupFixture(profile.prof.lg, profile.prof.ch); // the fixture is the hero and carries the only second line on the wall const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', { line: t.mode === 'cup' ? 'LEAGUE ' + fx.lg + ' · ' + fx.line : null }); 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(); go(t.diffs || t.kids ? 'modesub' : 'setup'); }); shelf.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(); }); 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 CLIMB PANEL: the arcade's setup face — the marathon's length (grown by every promotion) // and your best run. Reuses the league panel's furniture; the foes row stays empty (up to 18 // tiles is a wall, and the training picker already shows the roster face-up). function buildClimbPanel() { const lg = leagueOf(profile.prof.lg); document.getElementById('lgBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`; const name = document.getElementById('lgName'); name.textContent = 'THE CLIMB'; name.style.color = ''; const best = stats.read('all', undefined, stats.MODE_MATCH).longest; // two deliberate lines — as one string the browser broke it wherever it fell (Oliver) document.getElementById('lgSub').innerHTML = `THE CLUB FROM THE BOTTOM — ${lg.band[1]} STAND` + (best ? `
BEST RUN: ${best} FELLED` : ''); document.getElementById('lgFoes').innerHTML = ''; } // 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 = `${f.short}${k ? '×' + k : '—'}`; 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 ? '
BEST IN THE CUP: ' + cupRoundName(lg.id, best) : '
THE CUP IS THE WAY UP — NEVER ENTERED'); } // 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 cupMode = pickedMode === 'cup', climbMode = pickedMode === 'zombie'; document.getElementById('cStart').textContent = cupMode ? 'ENTER THE CUP' : 'SERVE UP'; document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex'; document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none'; // THE TEXT REDUCTION: two blocks on one screen, not two numbered steps document.getElementById('s01').textContent = cupMode ? 'YOUR LEAGUE' : climbMode ? 'THE CLIMB' : 'PLAYER'; if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel(); if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court; // the COURT section: the CUP is the league fixture — one ringed row, the home venue, no choice // (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list. const homeCourt = leagueOf(profile.prof.lg).court; // ...and the meters get their legend ONCE, in the heading, instead of a line under every court document.getElementById('s02').innerHTML = cupMode ? 'HOME COURTSET BY YOUR LEAGUE' : 'COURTPACE · BOUNCE'; courtList.classList.toggle('fixed', cupMode); 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 && !cupMode); el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt : open && el.dataset.id === picked); el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : ''; // an OPEN court says nothing — its two meters say it wordlessly (the text reduction). A locked // one names the league that opens it, and nothing else: the tile stays visible, as a goal should const sub = el.querySelector('i'); sub.textContent = open ? '' : 'LEAGUE ' + l.id; sub.hidden = open; } 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();} // --- 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. // 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 = '') => `
`; 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 = `` + `${c.name}` // 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') + ''; // 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 (pickedMode === 'cup') return; // the cup is the league fixture — the league picks 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); }); 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 SCREEN IS GONE (2026-08-29, Oliver: "das ist doch jetzt eigentlich obsolet"). It was // built for the 18-rung ladder, where four bands rolling out of a deep hat was a ceremony worth a // screen; a league cup shuffles 2-3 faces the setup's league panel already shows face-up. The // shuffle itself survives INSIDE the run (cupDraw rolls without replacement, the final settled) // and each round still announces itself on the walk-on modal — only the screen is deleted. The // cup's big button says ENTER THE CUP (syncSetup), which is all the ritual the moment needs. // (It said ENTER THE DRAW until 2026-08-31 — a draw is a field of entrants, and nothing has been // drawn since the rounds went deterministic. Oliver had to ask what it meant, which settled it.) // The screen's mechanics live in git history (like the lob) if the ceremony is ever asked back. // 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}`; // ---- 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]); // 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; start('train'); }); 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 = `${f.name}PUT YOU OUT` + '>'; 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 = `${f.name}` + `${String(n).padStart(2, '0')}`; 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 = `LEAGUE ${next.id}${next.band[0]} — ${next.band[1]}`; trainList.appendChild(goal); } } 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) => `
${label}${val}
`; // 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; 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) { 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 // THE CARD IS WHO YOU ARE, not what you have done (2026-08-31, the stats streamline): the // lifetime ledger lives on BODY COUNT, which is the screen for it. What stays here is the // CAREER — how far up the club you have got, and how far each cup let you. for (const [boxId, rows] of [ ['cdFigs1', [['LEAGUE', profile.prof.lg], ['CUPS WON', profile.prof.lg - 1 + profile.prof.ch], ['BEST CUP', profile.cupBest(profile.prof.lg)]]], ['cdFigs2', [['BODIES FELLED', g.kills + z.kills + c.kills], ['DEEPEST CLIMB', 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'; // ...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 HOLDEVERY CUP FROM HERE IS A DEFENCE` : `NEXT: ${fx.title}${fx.line}`; } 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 // 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() { // THE TEXT REDUCTION (2026-08-31): the two secondary doors carry a NUMBER each instead of a // description — what you have done, and your own best on the board they open. The board figure // is read locally on purpose: the splash does not fetch the world board. document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB'); document.getElementById('mBest').textContent = 'BEST ' + stats.bestScore(boardFor(pickedMode)).toLocaleString('en-GB'); // 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). 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; } // `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); } } // 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 = () => { stats.drawStats('all', SPRV); 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'); }; // the fixture button IS the tournament — it skips the setup screen on purpose: the cup has no // court to pick (it plays the league's own) and no opponent to choose, so that screen was a // confirmation page with nothing on it to confirm document.getElementById('mStart').addEventListener('click', () => { if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first pickMode('cup'); start('cup'); }); document.getElementById('mOther').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 = `${run.score.toLocaleString('en-GB')} ${B.unit} · ${run.sub} ${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 === '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 = `` + `${art.name}CHAPTER ${ch} · GAME ${ch * TUNE.bossEvery}`; 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 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); } 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 // ...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; } };