Streamline mobile menus and refresh splash logo
This commit is contained in:
1 parent
a399e4929c
commit
a09856f8c9
6 files changed
+569
-483
No files matched your search
+3
-32
@@ -833,7 +833,7 @@ export const MODES = [
|
||||
{ id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' },
|
||||
// THE CLIMB (2026-08-29): the arcade is the survival marathon through the WHOLE earned ladder,
|
||||
// bottom up — its length grows with the career; the cup stays the league fixture. See beats.
|
||||
{ id: 'zombie', name: 'ARCADE', sub: 'THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY', art: 'mode_arcade' },
|
||||
{ id: 'zombie', name: 'THE CLIMB', sub: 'THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY', art: 'mode_arcade' },
|
||||
// `tag` is the poster's corner chip (main.banner): the cup is THE WAY UP — winning your
|
||||
// league's tournament is the one route to promotion, and the picker should say so standing.
|
||||
{ id: 'cup', name: 'THE TOURNAMENT', sub: 'YOUR LEAGUE\'S ROUNDS · WIN IT TO RISE', art: 'mode_cup',
|
||||
@@ -846,37 +846,8 @@ export const MODES = [
|
||||
{ id: 'gore', name: 'SPLATTER GORE', sub: 'NO WARM-UP · SURVIVE THE DEAD CLUB', art: 'mode_gore' },
|
||||
];
|
||||
|
||||
// THE SETUP SCREEN'S TOP ROW (Oliver's two-step menu, 2026-08-14): three posters, and picking one
|
||||
// opens its own second step — classic deals the difficulty chips, zombie tennis deals its two
|
||||
// sub-mode cards (arcade / the cup), gore just IS gore. A row either points at a run mode in MODES
|
||||
// (`mode`) or carries its own card and the MODES ids it fans out to (`kids`).
|
||||
// THE ROOMS, NOT A SHELF (2026-08-31, THE SEASON). The wall used to deal five posters as equals
|
||||
// with the career hidden two taps inside ZOMBIE TENNIS, so nothing on screen said what any of them
|
||||
// was FOR — Oliver: "alles ist irgendwie beliebig". Three named groups answer that before a single
|
||||
// caption is read: one path up, a room to practise in, and a room where nothing counts. A `group`
|
||||
// row is a heading (main.buildTops), not a poster.
|
||||
//
|
||||
// The cup left the ZOMBIE TENNIS pocket for the top of the wall — it is the canonical path, and a
|
||||
// canonical path does not live behind a fan-out. Its two former siblings (the climb, the training
|
||||
// court) stand on their own in the training group, which also cost the second step its only other
|
||||
// job: CLASSIC's difficulty chips are all that still fan out.
|
||||
// THE TEXT REDUCTION (2026-08-31, design handoff): a room is ONE WORD and the posters carry only
|
||||
// their names. The straps went with the sentences — a shelf that has to explain itself is a shelf
|
||||
// nobody reads. `layout` is how the room deals its posters: the fixture is a HERO with the one
|
||||
// call on the screen, the training pair stands side by side, the casual three stack shorter.
|
||||
export const MENU = [
|
||||
{ group: 'SEASON', layout: 'hero' },
|
||||
{ mode: 'cup' },
|
||||
{ group: 'TRAINING', layout: 'pair' },
|
||||
{ mode: 'zombie' },
|
||||
{ mode: 'train' },
|
||||
{ group: 'CASUAL', layout: 'stack' },
|
||||
{ mode: 'gore' },
|
||||
{ mode: 'classic', diffs: true },
|
||||
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
|
||||
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
|
||||
{ name: 'VS A FRIEND', sub: 'LIVE 1V1 · REAL TENNIS · TWO PHONES', art: 'mode_vs', net: true },
|
||||
];
|
||||
// Additional solo modes. The splash owns the league fixture, Gore and Vs a Friend.
|
||||
export const MENU = [{ mode: 'zombie' }, { mode: 'train' }, { mode: 'classic' }];
|
||||
|
||||
// CLASSIC's difficulty. The first cut scaled only flub/quality-cap/speed and Oliver felt NOTHING —
|
||||
// the quality distributions overlapped (0.45 floor either way), ±12% speed never binds because the
|
||||
|
||||
+111
-241
@@ -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'));
|
||||
|
||||
Reference in new issue
Block a user