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

+53 -8
View File
@@ -961,13 +961,28 @@ death summary is the end menu, and the zombie bed ducks out as it comes in. Two
bed's gain at 0. Without it, a multi-megabyte file ducked mid-fetch comes up at full level several
seconds later, over the match.
**The overlay is a three-stage machine**, not a screen per DOM node: `#intro[data-stage]` swaps
`splash -> {courts, tutorial}` while the backdrop and card frame stay mounted, because a screen that
unmounts the overlay flashes the live court through between menus. **The title screen IS the menu** —
a separate menu stage behind it was the same logo on the same backdrop with two buttons, and read as
the title screen failing to advance. Court tiles SELECT only; `START GAME` and `BACK` are their own
buttons. The tutorial is a data table (`TUT` in main.js) of Marvin poses, an optional picture in
`assets/`, and his lines — adding a page is a row.
**The overlay is a four-stage machine**, not a screen per DOM node: `#intro[data-stage]` swaps
`splash -> {setup, stats, ranks}` while the backdrop and card frame stay mounted, because a screen
that unmounts the overlay flashes the live court through between menus. **The title screen IS the
menu** — a separate menu stage behind it was the same logo on the same backdrop with two buttons,
and read as the title screen failing to advance. Court tiles SELECT only; `SERVE UP` commits.
**THE TUTORIAL IS GONE** (was `TUT` in main.js, eight pages of Marvin). What it explained is already
taught in-game by the first-time-only modals, and its stage is the stats screen now. Deleted with
it: `tut_*.png`, `marvin1-4.png`, `btn_tut.png`, `btn_start.png`, and the `marvin` regen batch.
`x_howto.png` and `x_cross.png` LOOK tutorial-ish and are not — they are in-game modal art.
**Every menu screen is three bands**: something pinned to the top, something pinned to the bottom,
and the slack in between. `.card` is a full-height flex column, `.foot` uses `margin-top:auto` (drop
to the bottom of a short screen) plus `position:sticky` (stay there on a long one), and `#intro` is
the scrollport those stickies resolve against. `align-content` is **`safe center`**, never
`place-items: center` — plain centring on a grid whose item overflows pushes the item's top out of
the scrollport where nothing can reach it. Two registers, unchanged: **Bangers shouts** (screen
titles, the crimson primary button), **Silkscreen states** (every number, every label).
**The court cards' SPEED and BOUNCE meters are derived from `COURTS[].bounceXZ` / `.bounceY`**, not
from hand-written ratings — so a physics change moves the bars and the screen can never advertise a
court that plays differently from what it says.
**DEATH NO LONGER RELOADS THE PAGE.** `location.reload()` was an honest full reset and for a long
time the right call — but it also tore down the AudioContext, so the menu theme restarted from
@@ -985,11 +1000,41 @@ the title screen. `gameplay.restartRun()` replaces it:
**If you add module-level mutable state anywhere, it belongs in one of those reset functions.**
**Menu and tutorial art is generated DIRECTLY, not through `_regen.py`.** That pipeline keys magenta
**`stats.js` is the only thing that remembers anything.** One localStorage key, `splatterRuns`: an
array of per-run records (`{t, g, s, d, k, mk, dead, z, w}`), newest last, capped at 300. ALL TIME /
LAST MATCH / 7 DAYS are three filters over that one array, not three stored counters — a fourth
period is a filter, not a migration. It replaced `splatterBest`, which it migrates on first read.
- **The live run is module-local, not a field on `G`.** `G` is deep-cloned into `PRISTINE` at load
and restored wholesale by `restartRun()`; a counter living there would be reset by machinery that
knows nothing about stats.
- **`killZombie` takes an index, not a cause.** Eleven call sites reach it, so instead of threading
a weapon id through all of them (and through `wound()`, and through `splatter()`), every damage
loop in zombies.js stamps a module-level `CAUSE` before it can kill. Safe because a frame is one
synchronous pass. **The trap**: a kill path that fires OUTSIDE a stamped loop inherits a stale
cause — the flame ball's fly-past `ignite()` did exactly that and filed 'fire' kills under 'ball'.
Burning bodies carry `zb.burnBy` for the same reason: they die long after the thing that lit them.
- **`endRun` needs an explicit `banked` flag, not an "is the bag empty" inference.** The games count
lives on `G` and survives the flush, so a second call sails past a nothing-happened guard and
writes a duplicate with zero kills. `pagehide` really does fire more than once (bfcache dismiss
and restore), which is how this was caught. `tally()` clears the flag, so a fresh run re-arms
itself with no reset call for anyone to forget.
- `T.stats.check()` is the runnable check: it asserts the period filter and the aggregation against
a fixture, and `read()` takes an injected array precisely so it never touches a real save.
**Menu art is generated DIRECTLY, not through `_regen.py`.** That pipeline keys magenta
and slices figures out of a sheet; these are whole illustrated panels with backgrounds, so they go
through `generate_image.py` and a downscale/quantise pass, the same route the court tiles took. Two
things learned making them:
- **`assets/menu_bg.png` (the splash backdrop) was made by EDITING `court_grass.png`**, not from a
text prompt: `generate_image.py "<the changes>" --input assets/court_grass.png`. Edit mode holds
the camera, the palette and the line weight for free, and asking for a portrait aspect in the same
prompt worked. Then halve it exactly (`//2`, so the generator's ~4px art-pixel grid stays square —
a non-integer divisor shreds this art) and quantise at 128. 768x1376 -> 384x688, 80 KB.
- **No python3 on this machine has Pillow.** Run the downscale/quantise step as
`~/.local/bin/uv run --with pillow python - <<'PY'`. `generate_image.py` itself needs `requests`,
which `/opt/homebrew/bin/python3.11` has.
- **Quantise these at 128 colours, not 64.** The panels are big flat fields with a few saturated
accents (the ball, fire, blood), which is exactly where a small palette shows — 64 turned the
tennis ball beige.