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:
1 parent
bc10216d0b
commit
9133c0d351
12 files changed
+507
-19
No files matched your search
+86
-6
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user