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

+42 -13
View File
@@ -7,6 +7,7 @@ import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, wo
import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal, endBurst } from './gameplay.js';
import { tally, multiKill, endRun } from './stats.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
@@ -235,12 +236,13 @@ export function killPlayer() {
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; G.gunId = null; // everything stops
try { // the run is over — settle it against the best (games survived, sets as tiebreak)
const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
G.newBest = G.runGames > b.games; // beat it → the summary crows about it
if (G.newBest) { b.games = G.runGames; b.sets = G.sets[0]; localStorage.splatterBest = JSON.stringify(b); }
G.best = b;
} catch (e) { G.best = null; } // private mode etc. — the summary just skips the best line
// The run is over — bank it. endRun writes the record AND hands back the best run before this
// one, which is what the summary compares against. It replaced a hand-rolled localStorage.
// splatterBest read/write here: the best is now derived from the record rather than stored twice
// and left to drift. Timing is safe — restartRun() only fires on the tap AFTER death, so
// runGames/sets/splatT are all still intact at this point.
const b = endRun(G.runGames, G.sets[0], G.splatT);
G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets
}
// One hostile ball, lobbed at where the player is standing. `spread` fans a volley sideways.
@@ -309,6 +311,7 @@ export function fireBeam(dirX, dirZ) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
// out of the PLAYER at chest height — same point the cone is measured from, so what you see and
// what it kills are one geometry
CAUSE = 'laser';
G.beam = { x: P.x, y: TUNE.beamY + TUNE.beamOriginLift, z: P.z, ux, uz, t: TUNE.beamT };
zap(0.55); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08;
for (let i = zombies.length - 1; i >= 0; i--) {
@@ -336,6 +339,13 @@ export function fireBeam(dirX, dirZ) {
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
const GORE_MUL = { a: 1, b: 1, e: 1, f: 1.8, s: 2.5, h: 2.5, g: 4, k: 5, r: 3, m: 1.5, o: 0, B: 6 }; // a chapter boss comes apart like nothing else on the court; a barrel is not meat at all
// WHAT IS KILLING THINGS RIGHT NOW. killZombie takes an index, not a cause, and eleven call sites
// reach it — threading a weapon id through all of them (and through wound(), and through
// splatter()) to feed one number on a menu is not worth the churn. Every damage loop in this file
// instead stamps this before it can kill, and killZombie reads it. Safe because the whole thing is
// one synchronous frame: nothing interleaves between the stamp and the kill it causes.
let CAUSE = 'ball';
// A barrel bursts and hands you what was inside, armed. Pink debris rather than gore — it is a
// container, not a body — and the modal only fires the FIRST time you see each weapon, so the tenth
// rocket does not stop the world to explain itself.
@@ -395,6 +405,12 @@ function bossDeath(zb) {
function killZombie(i, quiet) {
const zb = zombies[i];
const mul = GORE_MUL[zb.variant] || 1;
// BEFORE the early returns below: the ball machine is furniture but it is still a thing you put
// down, so it earns a row. The barrel is loot and scores nothing anywhere else in the game —
// keep that agreement here too. A chapter boss files under its DESIGN, so the screen can name
// the one that has been killing you.
if (zb.variant !== 'o')
tally(zb.variant === 'B' ? 'B' + Math.max(0, BOSS_ART.indexOf(zb.art)) : zb.variant, CAUSE);
zombies.splice(i, 1);
if (zb.variant === 'B') bossDeath(zb); // before endBossFight below, so the card can wait on it
if (zb.variant === 'o') { popBarrel(zb); return; } // a container: it pays out and scores NOTHING
@@ -446,7 +462,9 @@ export function ignite(x, z, r) {
for (const zb of zombies) {
if (!zb || zb.burnT > 0) continue; // the boss DOES catch now — just at TUNE.bossBurnMul the rate,
// so fire is a real answer to a big body instead of a no-op
if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; if (zb.hp === undefined) zb.hp = 1; }
// remember WHAT lit it: a body that burns to death is credited to the flamer or the fire ball
// that started it, not to whatever happens to be firing when it finally goes down
if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; zb.burnBy = CAUSE; if (zb.hp === undefined) zb.hp = 1; }
}
}
@@ -469,6 +487,7 @@ function wound(zb, amount, fx = true) {
// caller must then stop touching zb — killZombie splices, and splatPoint can cascade further).
function burnTick(zb, i, dt) {
zb.burnT -= dt;
CAUSE = zb.burnBy || 'fire';
ignite(zb.x, zb.z, TUNE.burnSpread);
// fx off: the embers below already ARE the feedback, a wet pop per cooked hp point would be noise
const dead = wound(zb, TUNE.burnDps * (isBoss(zb) ? TUNE.bossBurnMul : 1) * dt, false);
@@ -546,6 +565,7 @@ function woundBurst(zb) {
// rather than "fixed", because a melee path to the boss would need him to cross the net first.
export function meleeSwipe(x, z, range) {
let hit = 0;
CAUSE = 'ball'; // the racket. No w_racket icon exists, and the swish IS the ball stroke — same row.
for (let i = zombies.length - 1; i >= 0; i--) {
const zb = zombies[i];
if (!zb || zb.variant === 'r') continue; // !zb: killZombie can sweep the court mid-loop
@@ -568,7 +588,8 @@ function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
if (!fanfare) return; // machine-gun pellets splatter but skip the streak fanfare / bomb meter
ball.kills++; // this shot's running streak
// escalating multi-kill fanfare — one shot, ≥2 zombies
if (ball.kills === 2) { G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); }
// ...and the stat is banked on the TRANSITION to 2, so a massacre of six is one multi-kill
if (ball.kills === 2) { multiKill(); G.flash = { text: 'DOUBLE KILL', t: 0.9 }; cheer(1.6, 0.45); voice('double_kill', 0.4); }
else if (ball.kills === 3) { G.flash = { text: 'TRIPLE KILL', t: 0.9 }; cheer(2.2, 0.6); voice('triple_kill', 0.4); }
else if (ball.kills >= 4) { G.flash = { text: `MASSACRE ×${ball.kills}`, t: 0.9 }; cheer(2.8, 0.7); voice('unbelievable', 0.4); }
}
@@ -592,7 +613,8 @@ export function firePellet(w = WEAPONS.gun) {
const pe = { x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s,
t: w.life ?? 1.2, r: w.r ?? TUNE.mgPelletR, dmg: w.dmg ?? TUNE.dmgPellet, hp: w.hp ?? TUNE.dmgPelletHp,
pierce: !!w.pierce, boom: !!w.boom, ignite: w.ignite ? (w.igniteR ?? 1.8) : 0,
trail: w.trail || 0, trailR: w.trailR, jet: w.jet || 0, col: w.col, glow: w.glow };
trail: w.trail || 0, trailR: w.trailR, jet: w.jet || 0, col: w.col, glow: w.glow,
wid: w.id || 'gun' }; // the round remembers which weapon threw it — the horde's plain gun has no row
// A LOBBED round: it is thrown, not sprayed. Solved so it comes down about where the crowd is, so
// "it throws itself" reads as aiming rather than as a flat stream that happens to be fat.
if (w.arc) {
@@ -662,7 +684,8 @@ function tickHorde(dt) {
// bomb ball: everything inside bombRadius splatters at once (each kill still ++ball.kills so
// the Phase-2 fanfare escalates), plus a crater — big stain, size-3 pile, extra particles, boom.
export function explode(x, z) {
export function explode(x, z, why = 'bomb') {
CAUSE = why; // the rocket detonates through here too, and it is its own row
for (let i = zombies.length - 1; i >= 0; i--) { // backwards: splatter() splices (and can sweep) the array
const zb = zombies[i];
if (!zb || Math.hypot(zb.x - x, zb.z - z) >= TUNE.bombRadius) continue;
@@ -783,7 +806,10 @@ export function updateZombies(dt) {
// The flame ball lights everything it FLIES PAST, not just what it kills. Without this the
// fire never actually spread: full pierce meant the ball killed its whole lane outright, and
// the only bodies left to catch were the ones it had already removed.
if (ball.active && ball.flame && ball.y < 2.6) ignite(ball.x, ball.z, TUNE.burnBallR);
// The flame ball lights what it FLIES PAST, outside the body loop below — so it has to stamp the
// cause itself. Without this the trail's burn kills inherited whatever CAUSE was left from the
// previous frame and filed under 'ball'.
if (ball.active && ball.flame && ball.y < 2.6) { CAUSE = ball.wid; ignite(ball.x, ball.z, TUNE.burnBallR); }
// The umpire called time. The changeover is a MANDATORY breather for the player — no serving, no
// walking — so it is one for the dead too, which is the joke: they wait, because it's still a
@@ -900,6 +926,7 @@ export function updateZombies(dt) {
&& Math.hypot(zb.x - P.x, (zb.z - P.z) * 0.75) < TUNE.eatenR + (zb.variant === 'g' ? 0.2 : 0)) // z weighted down: depth is foreshortened, so a head-on shambler eats you when it LOOKS on top of you
killPlayer();
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
CAUSE = ball.wid; // re-stamped per body: burnTick above shares this loop and moves it
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
if (d < hitR(zb) + ballSweep && !(zb.hitT > 0)) { // brute and boss are bigger targets
if (ball.bomb) { // detonate on first contact, whatever the pace
@@ -978,6 +1005,7 @@ export function updateZombies(dt) {
// boss) empties this array — so every remaining index reads undefined and the frame throws. Same
// guard, and the same reason, as the one in the shots loop and the body loop.
if (!pe) continue;
CAUSE = pe.wid;
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
let hit = false, landed = false;
if (pe.arc) { // a thrown round: it falls, and landing is a hit
@@ -1024,7 +1052,7 @@ export function updateZombies(dt) {
else if (dmgHp <= 0) { /* fire only: it is already alight from the ignite above */ }
else if ((zb.hp -= dmgHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
else killZombie(j, true);
if (pe.boom) { explode(pe.x, pe.z); hit = true; break; } // the rocket takes the crowd, not the body
if (pe.boom) { explode(pe.x, pe.z, pe.wid); hit = true; break; } // the rocket takes the crowd, not the body
hit = !pe.pierce; // a laser keeps going down the whole lane
if (hit) break;
}
@@ -1032,7 +1060,7 @@ export function updateZombies(dt) {
// A thrown round that reaches the ground goes off THERE, whether or not it touched a body —
// "like a bomb, the projectile is a rocket". Without this a rocket that missed simply expired.
if (!hit && landed) {
if (pe.boom) explode(pe.x, pe.z);
if (pe.boom) explode(pe.x, pe.z, pe.wid);
else if (pe.ignite) ignite(pe.x, pe.z, pe.ignite);
hit = true;
}
@@ -1080,6 +1108,7 @@ export function updateZombies(dt) {
for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones
const e = extras[i];
if (!e) continue; // same as pellets/shots: a kill here can start a beat that empties the array
CAUSE = e.wid || 'ball'; // a triple's wings; a parried machine ball is just a ball
e.t -= dt; e.vy -= (e.g || TUNE.gravity) * dt; // e.g: a clone flies under the SAME topspin as the ball it came from
e.x += e.vx * dt; e.y += e.vy * dt; e.z += e.vz * dt;
if (e.y <= 0 && e.vy < 0) { e.y = 0; e.vy = -e.vy * TUNE.bounceY; e.vx *= TUNE.bounceXZ; e.vz *= TUNE.bounceXZ; }