1350 lines
82 KiB
JavaScript
1350 lines
82 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 , storyFixture, storyRounds, storyAct , STORY, ROMAN, storyBossArt , STORY_HELP, STORE } 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 , startStory , storyUI } from './beats.js';
|
||
import { netConnect, netHooks, netAgain } from './net.js';
|
||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook, endHit, logGeom } from './render.js';
|
||
import { voice, ambient, menuMusic, menuDuck, sfx, boom, whoosh, applause, pock } 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 {} };
|
||
// THE ARCADE OPENS WITH THE STORY (Oliver, 2026-10-15: "nur die bereits über die Hauptstory
|
||
// freigeschalteten Spielmodi spielbar machen"): a door is open once the act that unlocks it is cleared
|
||
// (config.STORY acts[].arcade), and everything is open once the story is told. A locked door is shown,
|
||
// greyed, with the act that opens it — pointed at, never hidden. STORE.arcadePremium is the stub for
|
||
// making the arcade a premium door; premium() answers true until the unlock IAP exists.
|
||
const premium = () => true;
|
||
const arcadeAct = id => STORY.acts.findIndex(a => a.arcade.includes(id)) + 1; // 0 = never gated
|
||
const arcadeOpen = id => {
|
||
const n = arcadeAct(id), s = profile.prof.story;
|
||
return (!n || s.cp === 'done' || s.act > n) && (!STORE.arcadePremium || premium());
|
||
};
|
||
const lockLine = id => 'CLEAR ACT ' + ROMAN[arcadeAct(id)] + ' TO OPEN';
|
||
function buildTops() {
|
||
modeList.innerHTML = '';
|
||
for (const { mode } of MENU) {
|
||
const card = modeOf(mode), b = document.createElement('button'), open = arcadeOpen(mode);
|
||
b.className = 'mode' + (open ? '' : ' locked');
|
||
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
|
||
b.innerHTML = `<span class="cb"><b>${card.name}</b>${open ? '' : `<i>${lockLine(mode)}</i>`}</span>`;
|
||
b.addEventListener('click', () => {
|
||
if (!arcadeOpen(mode)) { b.classList.remove('nope'); void b.offsetWidth; b.classList.add('nope'); return; }
|
||
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 = '') {
|
||
// hosting is the arcade door act IV opens; joining a friend's room is always free
|
||
const host = arcadeOpen('vs');
|
||
for (const id of ['vsHostC', 'vsHostD']) document.getElementById(id).disabled = !host;
|
||
document.getElementById('vsHostLock').textContent = host ? '' : lockLine('vs');
|
||
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 if (back === 'story' && saved) start('story');
|
||
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.
|
||
// THE STORY IS THE MAIN PATH now (2026-10-15): the front door names where it stands
|
||
const fx = storyFixture(profile.prof.story);
|
||
document.getElementById('msTitle').textContent = fx.title;
|
||
// the arcade's gore door says what opens it while it is shut
|
||
const gOpen = arcadeOpen('gore'), mg = document.getElementById('mGore');
|
||
mg.classList.toggle('locked', !gOpen);
|
||
mg.querySelector('i').textContent = gOpen ? 'NO CUP. JUST CARNAGE.' : lockLine('gore');
|
||
document.getElementById('mCourt').style.display = gOpen ? '' : 'none'; // (its CSS display beats [hidden])
|
||
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 === 'story' ? leagueOf(storyAct(profile.prof.story.act).lg).court
|
||
: 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 STORY: the act's court, the splatter world under it, and the checkpoint the profile holds
|
||
// (the PROLOGUE is the one door that stays in 'tennis': a real match, the old turn armed in it)
|
||
if (modeId === 'story') { if (profile.prof.story.cp !== 'pro') intoSplatter(); startStory(); }
|
||
}
|
||
|
||
// 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());
|
||
// ---- THE STORY's screens (2026-10-15, PROJECT.md "THE STORY", phase 3) ------------------------------
|
||
// In-game, animated, in the storyboard's look: the act title card, the cup's ceremony with its loot,
|
||
// and the act card. Each pauses the world while it is up (the same `paused` the quit chip uses) and
|
||
// resolves with the door that closed it. beats asks for them through storyUI.show.
|
||
const sty = document.getElementById('sty');
|
||
let styClose = null, styDoors = false;
|
||
const art = n => `assets/${n}.png?v=${SPRV}`;
|
||
const courtOf = act => { const lg = leagueOf(storyAct(act).lg); return { lg, court: COURTS.find(c => c.id === lg.court) || COURTS[0] }; };
|
||
const ARCADE_NAMES = { train: 'TRAINING', classic: 'CLASSIC TENNIS', zombie: 'THE CLIMB', gore: 'SPLATTER GORE', vs: 'VS A FRIEND' };
|
||
const delay = (k, more = '') => `style="animation-delay:${(k * 0.15).toFixed(2)}s;${more}"`; // one style attribute per element
|
||
// THE SOUND OF THE SCREENS (Oliver: "kleine Soundeffekte — die Waffen könnten das Klacken haben, das
|
||
// auch beim Waffenwechsel im Gore-Mode spielt"). Each cue is timed to the animation it belongs to and
|
||
// is dropped if the screen is tapped away first.
|
||
let styTimers = [];
|
||
const at = (secs, fn) => styTimers.push(setTimeout(fn, secs * 1000));
|
||
const CLICK = () => sfx('weapon-click2.mp3', 0.7); // the rack's own click (gameplay.selectWeapon)
|
||
function storyScreen(kind, d) {
|
||
const act = d.act, { lg, court } = courtOf(act);
|
||
let html = '', glow = '#3a1d16', auto = 0;
|
||
styTimers.forEach(clearTimeout); styTimers = [];
|
||
if (kind === 'act') {
|
||
auto = 2.8;
|
||
glow = '#1d2233';
|
||
html = `<div class="bgc p" style="background-image:url(${art(court.tile)})"></div>
|
||
<div class="col">
|
||
<div class="lab gold r">${lg.name}</div>
|
||
<h1 class="huge s" ${delay(1)}>ACT ${ROMAN[act]}</h1>
|
||
<div class="big gold r" ${delay(3)}>${court.name}</div>
|
||
<div class="lab r" ${delay(5)}>WIN THE CUP · CLEAR THE COURT</div>
|
||
<img class="badge r" ${delay(6)} src="${art('badge_l' + lg.id)}" alt="">
|
||
<div class="lab tap">TAP</div>
|
||
</div>`;
|
||
whoosh(0.3); at(0.3, () => boom(0.45)); // the court flies in, ACT n lands
|
||
} else if (kind === 'draw') {
|
||
// THE TOURNAMENT TABLEAU (storyboard 03): the cup's rounds up the league, the final the act boss's
|
||
// body. After the act card it is the checkpoint (⚑ — a loss throws you back here); between rounds
|
||
// it stamps the round just won and points at the next face.
|
||
auto = 3.2;
|
||
glow = '#1a2030';
|
||
const R = storyRounds(act), last = R.length - 1;
|
||
html = `<div class="bgc dim" style="background-image:url(${art(court.tile)})"></div><div class="col">
|
||
<div class="lab gold r">ACT ${ROMAN[act]} · THE CUP${d.round ? '' : ' · ⚑ SAVED'}</div>
|
||
<div class="big r" ${delay(1)}>${['', 'ONE', 'TWO', 'THREE', 'FOUR', 'FIVE'][R.length]} ROUNDS</div>
|
||
<div class="draw">${R.map((r, i) => { const f = matchFoe(r.band[0]);
|
||
const st = i < d.round ? '<span class="won">WON</span>' : i === d.round ? '<span class="gold tap">NEXT</span>' : '';
|
||
return `<div class="r${i === last ? ' fin' : ''}${i < d.round ? ' done' : ''}" ${delay(2 + i)}>
|
||
<img src="${foeArt(f)}" alt=""><div><div class="lab${i === last ? ' gold' : ''}">${r.name}</div>
|
||
<div class="mid">${f.name}</div></div><div class="lab st">${st}</div></div>`; }).join('')}</div>
|
||
<div class="lab tap">TAP</div>
|
||
</div>`;
|
||
R.forEach((_, i) => at(0.3 + (2 + i) * 0.15, () => pock(0.4, 600 + i * 120)));
|
||
if (d.round) at(0.3 + (1 + d.round) * 0.15 + 0.3, () => boom(0.3)); // the WON stamp
|
||
} else if (kind === 'loot') {
|
||
const xd = profile.runDelta(), mid = storyBossArt(act, 'mid').name;
|
||
html = `<div class="col">
|
||
<div class="lab gold r">CUP WON · ${court.name}</div>
|
||
<div class="big r" ${delay(1)}>YOU CLEARED THE COURT</div>
|
||
<div class="stats r" ${delay(2)}>
|
||
<span class="lab">ROUNDS</span><b>${d.rounds} / ${d.rounds}</b>
|
||
<span class="lab">XP THIS RUN</span><b>+${xd.xp}</b>
|
||
<span class="lab">LEVEL</span><b>${xd.to}</b></div>
|
||
<div class="lab r" ${delay(3, 'color:#e8e8dc')}>…AND GAINED THESE FOR THE HORDES TO COME</div>
|
||
<div class="rack">${d.stock.map((e, k) => `<div class="d" style="animation-delay:${(0.7 + k * 0.22).toFixed(2)}s">
|
||
<img src="${art(WEAPONS[e.id].icon)}" alt=""><span class="lab pink">${WEAPONS[e.id].label} ×${e.n}</span></div>`).join('')}</div>
|
||
<div class="r" style="width:100%;display:grid;gap:6px;animation-delay:${(0.9 + d.stock.length * 0.22).toFixed(2)}s">
|
||
<div class="lab hot">CHAPTER ${ROMAN[act]} · ${STORY.length} GAMES</div>
|
||
<div class="pips">${Array.from({ length: STORY.length }, (_, k) => `<i class="${k === STORY.midAt - 1 ? 'm' : k === STORY.length - 1 ? 'b' : ''}"></i>`).join('')}</div>
|
||
<div class="row"><span class="lab gold">${STORY.midAt} · ${mid}</span><span class="lab hot">${STORY.length} · ???</span></div></div>
|
||
<button class="btn big r" ${delay(9)} data-door="go">TO THE COURT</button>
|
||
</div>`;
|
||
applause(2.4, 0.45); at(0.3, () => pock(0.5, 700));
|
||
// each tin lands at 70% of its 0.6 s drop — that is where it clicks into the rack
|
||
d.stock.forEach((e, k) => at(0.7 + k * 0.22 + 0.42, CLICK));
|
||
} else if (kind === 'clear') {
|
||
// THE FREE TIER ends here, on a win (STORE.freeTier, off until the unlock exists): act I's card
|
||
// names the next act and points at the full game instead of continuing into it
|
||
const wall = STORE.freeTier && !premium() && act === 1;
|
||
const done = act >= 5, next = done ? null : courtOf(act + 1);
|
||
const opens = storyAct(act).arcade.map(id => ARCADE_NAMES[id]).filter(Boolean);
|
||
glow = '#3b3216';
|
||
html = `<div class="col">
|
||
<div class="lab gold r">ACT ${ROMAN[act]} CLEARED</div>
|
||
<div class="big s" ${delay(1)}>${done ? 'THE DEAD CLUB IS QUIET' : court.name + ' IS QUIET'}</div>
|
||
<img class="badge s" ${delay(2)} src="${art(done ? 'badge_champ' : 'badge_l' + lg.id)}" alt="">
|
||
<div class="stats r" ${delay(3)}>
|
||
<span class="lab">BODIES</span><b>${stats.runKills ? stats.runKills() : 0}</b>
|
||
<span class="lab">BOSSES</span><b>2 / 2</b>
|
||
<span class="lab">DEATHS THIS ACT</span><b>${d.deaths}</b></div>
|
||
${opens.length ? `<div class="r" ${delay(5, 'display:grid;gap:6px')}><div class="lab">NEW IN ARCADE</div>
|
||
<div class="chips">${opens.map(n => `<span class="chip">${n}</span>`).join('')}</div></div>` : ''}
|
||
${next ? `<div class="next r" ${delay(7, `background-image:linear-gradient(#0006,#000e),url(${art(next.court.tile)})`)}>
|
||
<div><div class="lab gold">NEXT · ACT ${ROMAN[act + 1]}</div><div class="mid">${next.court.name}</div></div></div>`
|
||
: `<div class="mid gold r" ${delay(7)}>THE STORY IS TOLD</div>`}
|
||
${wall ? `<div class="lab gold r" ${delay(8)}>THE STORY CONTINUES IN THE FULL GAME</div>` : ''}
|
||
<button class="btn big r" ${delay(9)} data-door="${wall ? 'club' : 'next'}">${done || wall ? 'THE CLUBHOUSE' : 'CONTINUE'}</button>
|
||
${done || wall ? '' : `<button class="btn r" ${delay(10)} data-door="club">THE CLUBHOUSE</button>`}
|
||
</div>`;
|
||
applause(3.2, 0.55); at(0.4, () => boom(0.4)); // the act is down; the badge lands
|
||
opens.forEach((_, k) => at(0.85 + k * 0.12, () => pock(0.55, 1100 + k * 180)));
|
||
}
|
||
sty.style.setProperty('--sty-glow', glow);
|
||
sty.innerHTML = html; sty.hidden = false; paused = true;
|
||
styDoors = /data-door/.test(html);
|
||
const t0 = performance.now();
|
||
return new Promise(res => {
|
||
styClose = door => {
|
||
if (performance.now() - t0 < 700) return; // a tap still on its way from the court cannot skip it
|
||
sty.hidden = true; sty.innerHTML = ''; paused = false; styClose = null;
|
||
styTimers.forEach(clearTimeout); styTimers = [];
|
||
res(door);
|
||
};
|
||
if (auto) setTimeout(() => styClose && styClose('auto'), auto * 1000);
|
||
});
|
||
}
|
||
sty.addEventListener('click', e => {
|
||
if (!styClose) return;
|
||
const b = e.target.closest('[data-door]');
|
||
if (b) styClose(b.dataset.door); else if (!styDoors) styClose('tap');
|
||
});
|
||
storyUI.show = (kind, d) => storyScreen(kind, d).then(door => {
|
||
// the act card opens onto the cup's tableau — the checkpoint the cup starts from
|
||
if (kind === 'act') return storyScreen('draw', { act: d.act, round: 0 });
|
||
if (kind !== 'clear') return;
|
||
// the act card IS the act's ending: the run closes here, and the story picks up where it now stands
|
||
restartRun(); profile.push(); refreshSplash();
|
||
intro.hidden = false; quitChip.hidden = true;
|
||
if (door === 'next' && profile.prof.story.cp !== 'done') start('story'); else go('splash');
|
||
});
|
||
|
||
const goMode = () => { buildTops(); go('mode'); };
|
||
document.getElementById('mStart').addEventListener('click', () => {
|
||
if (!profile.prof.setup) { playerReturn = 'story'; buildPlayer(); go('player'); return; }
|
||
start('story');
|
||
});
|
||
document.getElementById('mOther').addEventListener('click', goMode);
|
||
// RESET STORY (Oliver, 2026-10-15: "ich brauche zumindest während des Tests einen Button, der meinen
|
||
// Spielstand wieder resettet"). Two taps, so a stray one costs nothing. It puts the story back at the
|
||
// prologue and takes back what the story hands out — league, cups, the champion's flag — and keeps the
|
||
// level (xp is the server mirror's one-way ratchet). The push makes the mirror agree at once: pull()
|
||
// moves the story only forward, and a mirror still holding act III would hand it straight back.
|
||
{
|
||
const b = document.getElementById('mReset');
|
||
b.hidden = !STORE.testReset;
|
||
let armT = -Infinity; // (0 would count as armed in a page younger than 3 s)
|
||
b.addEventListener('click', () => {
|
||
if (performance.now() - armT > 3000) { armT = performance.now(); b.textContent = 'TAP AGAIN TO RESET'; b.classList.add('arm'); return; }
|
||
const p = profile.prof;
|
||
p.story = profile.freshStory(); p.lg = 1; p.ch = 0; p.cups = {}; p.at = {}; p.out = 0;
|
||
profile.save(); profile.push();
|
||
armT = -Infinity; b.textContent = 'STORY RESET'; b.classList.remove('arm');
|
||
refreshSplash(); buildTops();
|
||
setTimeout(() => { b.textContent = 'RESET STORY'; }, 1600);
|
||
});
|
||
}
|
||
document.getElementById('mVs').addEventListener('click', () => vsOpen());
|
||
document.getElementById('mGore').addEventListener('click', () => {
|
||
if (!arcadeOpen('gore')) return;
|
||
pickMode('gore'); start('gore');
|
||
});
|
||
document.getElementById('mCourt').addEventListener('click', () => {
|
||
if (!arcadeOpen('gore')) return;
|
||
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;
|
||
const story = !!G.story; // restartRun wipes G — the story's place is in the profile
|
||
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; }
|
||
// THE STORY: TRY AGAIN / CONTINUE re-enter at the checkpoint; anywhere else is the club
|
||
if (story) {
|
||
if (door === 'help') { profile.prof.story.help = Math.min(STORY_HELP.max, profile.prof.story.help + 1); profile.save(); }
|
||
if (door === 'story' || door === 'help') start('story'); else go('splash');
|
||
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') { start('story'); return; } // THE STORY is the fixture now
|
||
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
|
||
else if (G.logShow) { // ...and a tap on the open log COPIES all of it, to paste into a debug session
|
||
const L = logGeom();
|
||
if (x >= L.x && x <= L.x + L.w && y >= L.y && y <= L.y + L.h) copySwingLog();
|
||
}
|
||
});
|
||
// The whole ring as text (Oliver, 2026-10-14: "ich würde dir das gerne mal zum Debuggen geben"):
|
||
// a header naming the build, the mode and the body across the net, then one line per entry with
|
||
// its measured fields. Clipboard first (a tap is the user gesture it needs); the share sheet where
|
||
// a webview refuses it.
|
||
function copySwingLog() {
|
||
const v = (document.querySelector('script[src*="main.js"]')?.src.match(/v=(\d+)/) || [])[1] || '?';
|
||
const foe = G.match && !G.net ? matchFoe(G.match.opp) : null;
|
||
const head = `SPLATTER TENNIS v${v} · ${G.match ? (G.match.cup ? 'cup' : G.match.train ? 'training' : 'climb') : G.classic ? 'classic' : G.mode}`
|
||
+ (foe ? ` · ${foe.short} (${foe.style})` : G.diff ? ` · ${G.diff.name}` : '') + ` · ${new Date().toISOString()}`;
|
||
const lines = G.missLog.map(e => {
|
||
const { at, result, reason, ...rest } = e;
|
||
const extra = Object.keys(rest).length ? ' {' + Object.entries(rest).map(([k, val]) => k + '=' + val).join(' ') + '}' : '';
|
||
return `${at}s ${result} ${reason}${extra}`;
|
||
});
|
||
const text = [head, ...lines].join('\n');
|
||
const done = ok => { G.flash = { text: ok ? 'SWING LOG COPIED' : 'COPY FAILED', t: 1.2 }; };
|
||
const share = () => navigator.share ? navigator.share({ text }).then(() => done(true), () => done(false)) : done(false);
|
||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(() => done(true), share);
|
||
else share();
|
||
}
|
||
|
||
|
||
|
||
// ---- 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
|
||
profile, startStory, // THE STORY: read and set its place from the console (prof.story)
|
||
|
||
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|