The leagues, phase 1: the profile clock ticks — xp, levels, the identity screen, the server mirror

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 12:22:33 +02:00
1 parent bc10216d0b
commit 9133c0d351
12 files changed
+507 -19

No files matched your search

+86 -6
View File
@@ -14,6 +14,7 @@ import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss
import { cv } from './dom.js';
import { haptic } from './haptics.js';
import * as stats from './stats.js';
import * as profile from './profile.js';
import './input.js';
let last = performance.now();
@@ -80,7 +81,10 @@ 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;
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
// 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.
const go = stage => { if (stage === 'setup') syncSetup(); intro.dataset.stage = stage; menuMusic(); };
// --- 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
@@ -236,6 +240,7 @@ 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', '');
@@ -252,12 +257,15 @@ for (const t of TOURS) {
// 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');
const lookCv = document.getElementById('look'), pLookCv = document.getElementById('pLook');
let lookPoll = 0;
function refreshLook() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const ok = drawLook(lookCv, t, KITS.find(e => e.id === pickedKit) || KITS[0],
SKINS.find(e => e.id === pickedSkin) || SKINS[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);
clearTimeout(lookPoll);
if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly
}
@@ -271,6 +279,7 @@ const swatches = (rowId, nameId, table, keyName, get, set) => {
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);
@@ -291,6 +300,57 @@ swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id
swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
// 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.
function syncSetup() {
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(); summary();
}
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
// localStorage keys, stats' name key), so the two screens cannot disagree — which is why the rows
// are REBUILT on every visit: their .on states have to reflect taps made on the other screen.
const pName = document.getElementById('pName');
function buildPlayer() {
pName.value = stats.savedName() || profile.zombieName();
const tRow = document.getElementById('pTours');
tRow.innerHTML = '';
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.textContent = t.name.replace(' SINGLES', '');
b.addEventListener('click', () => {
pickedTour = t.id;
try { localStorage.setItem('tour', t.id); } catch {}
for (const el of tRow.children) el.classList.toggle('on', el === b);
refreshLook();
});
tRow.appendChild(b);
}
document.getElementById('pKitRow').innerHTML = '';
document.getElementById('pSkinRow').innerHTML = '';
swatches('pKitRow', 'pKitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
swatches('pSkinRow', 'pSkinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
}
document.getElementById('pDice').addEventListener('click', () => { pName.value = profile.zombieName(); });
document.getElementById('pBack').addEventListener('click', () => go('splash')); // setup stays open — it re-asks
document.getElementById('pGo').addEventListener('click', () => {
// an emptied field is not a refusal to have a name — the die decides, like the prefill did
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
try { localStorage.setItem('tour', pickedTour); } catch {}
profile.prof.setup = 1; profile.save(); profile.push();
buildTops(); go('mode');
});
pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pGo').click(); });
// --- court rows, built from the table.
// The two meters come out of the court's OWN bounce numbers rather than a pair of hand-written
// ratings, so a physics change moves the bars with it and the screen can never advertise a court
@@ -429,6 +489,11 @@ function summary() {
// has just ended and the count has moved.
function refreshSplash() {
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
// the level strip — the meta clock, re-read whenever the splash comes back because a run has
// just moved it (same rule as the kill count above)
const pr = profile.progress();
document.getElementById('xpLv').textContent = 'LEVEL ' + pr.level;
document.getElementById('xpFill').style.width = Math.round(pr.frac * 100) + '%';
summary();
}
@@ -467,8 +532,14 @@ const openStats = () => {
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
};
// entering a question screen rebuilds it, so the LAST tag always names the run actually played
const goMode = () => { buildTops(); go('mode'); };
// 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');
};
document.getElementById('mStart').addEventListener('click', goMode);
document.getElementById('mStats').addEventListener('click', openStats);
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
@@ -669,6 +740,9 @@ document.getElementById('stModes').addEventListener('click', e => {
stats.drawStats(stPeriod, SPRV, stMode);
});
refreshSplash();
// the profile mirror: adopt a server copy that is ahead (a wiped phone, a second device), then
// let the strip show whatever came back. Fire-and-forget — the menu never waits on the network.
profile.pull().then(refreshSplash);
// 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.
@@ -731,7 +805,11 @@ addEventListener('keydown', e => {
// 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.
@@ -749,6 +827,7 @@ const maybeRestart = () => {
const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
if (G.modal || !ready) return;
restartRun();
profile.push(); // the run's xp is banked — mirror it while the moment is quiet
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;
quitChip.hidden = true; // the door out goes with the run it belonged to
@@ -773,6 +852,7 @@ document.getElementById('qNo').addEventListener('click', () => { qAsk.hidden = t
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');