Streamline mobile menus and refresh splash logo

This commit is contained in:
wuwi committed 2026-09-07 13:26:17 +02:00
1 parent a399e4929c
commit a09856f8c9
6 files changed
+569 -483

No files matched your search

+111 -241
View File
@@ -73,10 +73,7 @@ setupPoint();
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.
// 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; } };
@@ -90,6 +87,7 @@ let pickedTour = remembered('tour') || TOURS[0].id;
// 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();
@@ -112,99 +110,39 @@ function dealStage(stage) {
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.
// 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 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 = `<span class="cb"><b>${card.name}</b>${opts.line ? `<i>${opts.line}</i>` : ''}</span>`
// the standing answer to "how do I advance", worn by the poster it is true of (config MODES)
+ (card.tag ? `<u class="way">${card.tag}</u>` : '');
return b;
};
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
// TAP ADVANCES. Each screen's tap both answers and moves on — the wizard's whole promise is one
// decision per screen, and a NEXT button under a single row of choices is a second tap for nothing.
// (NETPLAY is the exception and has a screen of its own — see THE LOBBY below.)
function buildSub(top) {
modeSub.innerHTML = '';
const t = top || topOf(pickedMode);
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
// 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 = `<b>${d.name}</b><span class="pips">`
+ [0, 1, 2].map(k => `<i${k <= i ? ' class="on"' : ''}></i>`).join('') + '</span>';
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 = `<header><b>${t.group}</b></header><div class="shelf"></div>`;
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 });
for (const { mode } of MENU) {
const card = modeOf(mode), b = document.createElement('button');
b.className = 'mode';
b.style.backgroundImage = `url(assets/${card.art}.png?v=${SPRV})`;
b.innerHTML = `<span class="cb"><b>${card.name}</b></span>`;
b.addEventListener('click', () => {
// 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');
pickMode(mode);
go(mode === 'train' ? 'train' : 'setup');
});
shelf.appendChild(b);
modeList.appendChild(b);
}
}
buildTops();
buildSub();
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
@@ -266,7 +204,7 @@ const vsQuit = () => { endNetVs(); vsOpen(); }; // a room you walked out of
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();
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)
@@ -279,38 +217,20 @@ netHooks.lost = 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'),
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];
// 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);
// 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
}
@@ -341,26 +261,8 @@ const swatches = (rowId, nameId, table, keyName, get, set) => {
};
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 ? `<br>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.
@@ -395,48 +297,44 @@ function buildLeaguePanel() {
: '<br>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).
// A setup contains only decisions for this match. Identity lives in the player screen.
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();
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;
// 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 COURT<span>SET BY YOUR LEAGUE</span>' : 'COURT<span>PACE · BOUNCE</span>';
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);
let locked = 0;
for (const el of courtButtons) {
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;
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++;
}
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();}
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
@@ -444,6 +342,8 @@ function syncSetup() {
// 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 = '';
@@ -466,17 +366,19 @@ function buildPlayer() {
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
});
function leavePlayer(saved) {
const back = playerReturn; playerReturn = null;
if (back === 'card') { buildCard(); go('card'); }
else if (back === 'setup') go('setup');
else if (back === 'cup' && saved) { pickMode('cup'); start('cup'); }
else go('splash');
}
document.getElementById('pBack').addEventListener('click', () => leavePlayer(false));
document.getElementById('pGo').addEventListener('click', () => {
// 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');
leavePlayer(true);
});
pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pGo').click(); });
@@ -493,6 +395,7 @@ 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.
@@ -510,34 +413,23 @@ for (const c of COURTS) {
// 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);
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 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).
// 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
@@ -551,7 +443,7 @@ function buildTrain() {
const out = profile.prof.out;
const rung = n => {
const f = matchFoe(n), b = document.createElement('button');
b.addEventListener('click', () => { trainRung = n; start('train'); });
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
@@ -582,7 +474,7 @@ function buildTrain() {
trainList.appendChild(goal);
}
}
document.getElementById('trBack').addEventListener('click', () => go('setup'));
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 —
@@ -661,35 +553,25 @@ function buildCard() {
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 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]);
});
}
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; // EDIT PLAYER borrows the identity screen and comes back to the card
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
@@ -732,13 +614,11 @@ document.getElementById('prGo').addEventListener('click', () => {
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;
// 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 keeps career progress on the plaque; detailed records live behind STATS and BOARD.
// 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
@@ -756,6 +636,8 @@ function refreshSplash() {
const fx = cupFixture(profile.prof.lg, profile.prof.ch);
document.getElementById('msTitle').textContent = fx.title;
document.getElementById('msLine').textContent = fx.line;
document.getElementById('mCourt').textContent = 'COURT · ' +
(COURTS.find(c => c.id === picked) || COURTS[0]).name;
}
@@ -789,8 +671,7 @@ function start(modeId = pickedMode) {
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
// Career totals and detailed records live one level beneath the profile.
const openStats = () => {
stats.drawStats('all', SPRV); go('stats');
};
@@ -804,6 +685,7 @@ const recordingMeta = () => ({
});
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.');
@@ -841,26 +723,20 @@ document.addEventListener('visibilitychange', () => {
}
});
addEventListener('pagehide', () => recorder.saveRecording());
// 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
const goMode = () => { buildTops(); go('mode'); };
document.getElementById('mStart').addEventListener('click', () => {
if (!profile.prof.setup) { buildPlayer(); go('player'); return; } // the club wants a name first
if (!profile.prof.setup) { playerReturn = 'cup'; buildPlayer(); go('player'); return; }
pickMode('cup'); start('cup');
});
document.getElementById('mOther').addEventListener('click', goMode);
document.getElementById('mVs').addEventListener('click', () => vsOpen());
document.getElementById('mGore').addEventListener('click', () => {
pickMode('gore'); start('gore');
});
document.getElementById('mStats').addEventListener('click', openStats);
document.getElementById('mCourt').addEventListener('click', () => {
pickMode('gore'); go('setup');
});
document.getElementById('cdStats').addEventListener('click', openStats);
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
// whose names you came to look at
let rkMode = stats.MODE_GORE;
@@ -927,20 +803,14 @@ nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getEle
// 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'])
// 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'));
// 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('stBack').addEventListener('click', () => { buildCard(); go('card'); });
document.getElementById('sBack').addEventListener('click', () => {
const t = topOf(pickedMode);
if (t.diffs || t.kids) { buildSub(); go('modesub'); }
if (pickedMode === 'gore') go('splash');
else if (pickedMode === 'train') go('train');
else goMode();
});
document.getElementById('bsBack').addEventListener('click', () => go('secret'));