Trade the tutorial for a screen that counts the dead

The menus were placeholders next to the art. Three screens now: a title over
an empty, dimmed, blood-stained court; a MATCH SETUP screen where each court
wears meters read straight off its own bounce numbers; and BODY COUNT, which
needed the game to start measuring anything at all.

stats.js keeps one capped array of per-run records — all time, last match and
seven days are three filters over it rather than three counters that can drift
apart. Kills file under the body AND the weapon: killZombie only ever knew an
index, so every damage loop stamps the cause on its way past.

The tutorial is gone. Marvin explained what the in-game modals already teach,
and his stage is where the stats live now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-04 16:01:59 +02:00
1 parent 411bff8724
commit f3b0b791d6
21 files changed
+619 -210

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
// stats.js — what the game remembers about you, and the two menu screens that read it back.
//
// Everything the BODY COUNT screen shows comes out of ONE localStorage key: an array of per-run
// records, newest last. ALL TIME / LAST MATCH / 7 DAYS are three filters over that array rather
// than three stored counters, so a period can be added later without a migration and without a
// second write path that can drift out of step with the first.
//
// The live run is a module-local bag, not a field on G. G is deep-cloned into PRISTINE at load and
// restored wholesale by restartRun(), so a counter living there would be reset by machinery that
// knows nothing about stats — fine today, and exactly the kind of coupling that breaks quietly the
// first time the reset order changes. endRun() is the only thing that empties this bag.
import { WEAPONS, ENEMIES } from './config.js';
const KEY = 'splatterRuns';
// ponytail: the array is capped, so "ALL TIME" means "your last CAP runs". At ~170 bytes a record
// that is ~50 KB against a 5 MB quota, and 300 deaths is months of play. If anyone ever passes it,
// the upgrade is a second key holding rolled-up lifetime totals that the cap never touches — not a
// bigger cap, which just moves the cliff.
const CAP = 300;
// A run in progress. `z` is kills by enemy row key, `w` is kills by weapon id.
const bag = () => ({ k: 0, mk: 0, z: {}, w: {} });
let run = bag();
// Has the CURRENT run already been written? An emptied bag is not enough to answer that: the games
// count lives on G and survives the flush, so a second endRun would sail past a "nothing happened"
// guard and bank a duplicate with zero kills. `pagehide` really does fire more than once — the
// bfcache dismisses and restores the same page — which is exactly how this was caught.
// Cleared by the next kill, so a fresh run re-arms itself with no reset call to forget.
let banked = false;
// ---------------------------------------------------------------- storage ----
// Private-mode Safari throws on both read and write. Stats are a nicety; a menu that will not open
// is not. Every path through this file degrades to "no stats" and never to an exception.
function load() {
try {
const a = JSON.parse(localStorage.getItem(KEY) || 'null');
if (Array.isArray(a)) return a;
return migrate();
} catch { return []; }
}
// The only number the game used to keep was localStorage.splatterBest = {games, sets}, written
// inside killPlayer. Rather than orphan it, it comes across as a single synthetic run the first
// time this module looks — so an existing player's best survives the upgrade instead of resetting
// to zero on the device they actually play on.
function migrate() {
try {
const b = JSON.parse(localStorage.splatterBest || 'null');
if (!b || !b.games) return [];
const seed = [{ t: Date.now(), g: b.games, s: b.sets || 0, d: 0, k: 0, mk: 0, dead: 1, z: {}, w: {} }];
localStorage.setItem(KEY, JSON.stringify(seed));
localStorage.removeItem('splatterBest'); // one source of truth from here on
return seed;
} catch { return []; }
}
const save = a => { try { localStorage.setItem(KEY, JSON.stringify(a.slice(-CAP))); } catch {} };
// ------------------------------------------------------------- collection ----
// One kill. `variant` is the zombie's own char; 'a' and 'b' are one shambler in two shirts and fold
// together, and a chapter boss arrives already keyed 'B0'..'B5' by design (see zombies.killZombie).
export function tally(variant, weapon) {
const z = variant === 'b' ? 'a' : variant;
banked = false; // something happened: this is a live run again
run.k++;
run.z[z] = (run.z[z] || 0) + 1;
run.w[weapon] = (run.w[weapon] || 0) + 1;
}
// A shot that took two or more at once — counted ONCE per shot, on the transition to 2, so a
// massacre of six is one multi-kill and not five.
export function multiKill() { run.mk++; }
// The run is over: fold it into the record and hand back the best run so far. `dead` separates a
// death from a tab closed mid-match — both are worth recording, only one is a death.
// Idempotent: a run with nothing in it writes nothing, so death-then-close cannot double-count and
// closing the tab at the menu does not invent an empty match.
export function endRun(games, sets, secs, dead = true) {
const prev = load();
const best = prev.reduce((b, r) => r.g > b.games ? { games: r.g, sets: r.s } : b, { games: 0, sets: 0 });
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false }; }
const newBest = games > best.games;
prev.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0),
k: run.k, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
save(prev);
run = bag(); banked = true;
return { ...best, newBest };
}
// ------------------------------------------------------------- aggregation ----
// `runs` is injectable so check() can assert against a fixture without touching localStorage.
export function read(period, runs = load()) {
const rs = period === 'last' ? runs.slice(-1)
: period === '7d' ? runs.filter(r => Date.now() - r.t < 7 * 864e5)
: runs;
const out = { kills: 0, multi: 0, deaths: 0, longest: 0, matches: rs.length, z: {}, w: {} };
for (const r of rs) {
out.kills += r.k || 0; out.multi += r.mk || 0; out.deaths += r.dead || 0;
out.longest = Math.max(out.longest, r.g || 0);
for (const k in r.z) out.z[k] = (out.z[k] || 0) + r.z[k];
for (const k in r.w) out.w[k] = (out.w[k] || 0) + r.w[k];
}
return out;
}
// the two figures the splash carries
export function total() {
const a = read('all');
return { kills: a.kills, best: a.longest };
}
// ponytail: a PLACEHOLDER rank until the leaderboard task lands. Deterministic in your own kill
// count, so it never reshuffles between opens, and shaped like a real ladder — steep at the bottom,
// flattening out. It means nothing and is not stored anywhere.
export const rank = kills => Math.max(1, Math.ceil(48000 * Math.exp(-kills / 900)));
const n = x => x.toLocaleString('en-GB');
const wIcon = id => id === 'ball' ? 'w_ball' : WEAPONS[id] ? WEAPONS[id].icon : null;
// ----------------------------------------------------------------- screens ----
// Both screens are built here rather than in main.js: they are the only readers of the shapes
// above, and main.js is the boot file, not a view layer.
export function drawStats(period, v) {
const d = read(period);
document.getElementById('stPeriod').textContent =
period === 'last' ? 'LAST MATCH' : period === '7d' ? '7 DAYS' : 'ALL TIME';
document.getElementById('stFigs').innerHTML = [
['KILLS', n(d.kills)], ['MULTI-KILLS', n(d.multi)], ['DEATHS', n(d.deaths)],
].map(([k, val], i) => `<div class="fig${i === 2 ? ' bad' : ''}"><b>${k}</b><i>${val}</i></div>`).join('');
// sorted by count, and only bodies you have actually met — an empty roster of everything would
// read as a checklist of things to go and do, which this screen is not
const rows = Object.entries(d.z).filter(([, c]) => c > 0).sort((a, b) => b[1] - a[1]);
const top = rows.length ? rows[0][1] : 1;
document.getElementById('stZ').innerHTML = rows.length ? rows.map(([k, c]) => {
// a key with no row is a save written by an older build: name it off the key, stripped, because
// this is the one string on the screen that does not come from our own tables
const e = ENEMIES[k] || { name: k.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || '?', spr: 'z_a1', sub: '' };
return `<div class="zrow${e.boss ? ' boss' : ''}">
<img src="assets/${e.spr}.png?v=${v}" alt="">
<div class="zb"><b>${e.name}</b><span>${n(c)}</span>
<div class="pb"><s style="width:${Math.max(3, c / top * 100)}%"></s></div>
<i>${e.sub}</i></div></div>`;
}).join('') : '<p class="none">NOTHING YET. GO AND EARN A ROW.</p>';
const ws = Object.entries(d.w).filter(([id, c]) => c > 0 && wIcon(id)).sort((a, b) => b[1] - a[1]);
document.getElementById('stW').innerHTML = ws.length ? ws.map(([id, c]) =>
`<div class="wtile"><img src="assets/${wIcon(id)}.png?v=${v}" alt=""><b>${n(c)}</b></div>`).join('')
: '<p class="none">NO WEAPONS FIRED.</p>';
document.getElementById('stFoot').innerHTML =
`<b>LONGEST RUN</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`;
}
// ponytail: the ladder is FAKE — filler names around your own entry, seeded off your kill count so
// the table is stable between opens. It exists so the button is not a dead end before the real
// leaderboard task; delete this whole function and the #ranks stage when that lands.
const FILLER = ['DEUCE_COURT', 'NETCORD', 'ADVANTAGE', 'BREAKPOINT', 'LOVEGAME', 'TIEBREAK',
'DROPSHOT', 'BASELINE', 'ACEHOLE', 'TOPSPIN', 'LETCALL', 'SMASHER'];
export function drawRanks() {
const { kills } = total(), me = rank(kills);
const rows = [];
for (let i = -4; i <= 4; i++) {
if (i === 0) { rows.push({ r: me, name: 'YOU', k: kills, me: true }); continue; }
const r = Math.max(1, me + i * (1 + (me % 7)));
if (rows.some(x => x.r === r) || r === me) continue;
// deterministic in the rank itself, so the same neighbours come back every time
rows.push({ r, name: FILLER[r % FILLER.length] + (r % 97), k: Math.max(0, Math.round(kills - i * 37)) });
}
rows.sort((a, b) => a.r - b.r);
document.getElementById('rkList').innerHTML = rows.map(x =>
`<div class="rkrow${x.me ? ' me' : ''}"><b>#${n(x.r)}</b><span>${x.name}</span><i>${n(x.k)}</i></div>`).join('');
}
// ------------------------------------------------------------------ check ----
// The one runnable check: the period filter and the aggregation, against a fixture. `read` takes an
// injected array precisely so this needs no localStorage and cannot disturb a real save.
export function check() {
const day = 864e5, now = Date.now();
const fx = [
{ t: now - 9 * day, g: 3, s: 0, d: 60, k: 10, mk: 1, dead: 1, z: { a: 8, g: 2 }, w: { ball: 10 } },
{ t: now - 2 * day, g: 7, s: 1, d: 90, k: 5, mk: 0, dead: 1, z: { g: 5 }, w: { bomb: 5 } },
{ t: now - 1000, g: 1, s: 0, d: 10, k: 1, mk: 2, dead: 0, z: { a: 1 }, w: { ball: 1 } },
];
const all = read('all', fx), wk = read('7d', fx), last = read('last', fx);
const eq = (got, want, what) => { if (got !== want) throw new Error(`${what}: ${got} !== ${want}`); };
eq(all.kills, 16, 'all kills');
eq(all.deaths, 2, 'all deaths'); // the abandoned run is recorded but is not a death
eq(all.longest, 7, 'all longest');
eq(all.z.a, 9, 'all shamblers');
eq(wk.kills, 6, '7d kills'); // the 9-day-old run is outside the window
eq(wk.z.a, 1, '7d shamblers');
eq(last.kills, 1, 'last kills');
eq(last.multi, 2, 'last multi');
eq(Object.entries(all.z).sort((a, b) => b[1] - a[1])[0][0], 'a', 'sort');
eq(rank(0) > rank(5000), true, 'rank improves with kills');
return 'ok';
}