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.
+1 -15
View File
@@ -444,20 +444,6 @@
"subject": "Three frames of the same tennis opponent, drawn as coarse low-resolution pixel art. FIRST a plain standing frame for scale, exactly like the reference. THEN TWO frames of him standing at the baseline BOUNCING A BALL on the ground before serving, with NO ball drawn. Every figure is the SAME man as the reference and the SAME size: RED polo shirt, dark grey shorts, RED cloth headband, blond hair, seen from the FRONT. Same face, same colours, same proportions. His headband is RED, the same red as his shirt \u2014 never white, cream or grey. In both bouncing frames his racket is held out to the side in his RIGHT hand \u2014 which is on the LEFT-HAND SIDE OF THE IMAGE \u2014 and his other arm is stretched DOWN AND OUT to the RIGHT-HAND SIDE OF THE IMAGE, well clear of his body, with the palm open and facing the ground as if patting a ball. CRITICAL: the two bouncing frames are otherwise the IDENTICAL drawing \u2014 same stance, same legs, same head, same racket, same size \u2014 and the ONLY difference between them is the height of that patting hand: in the first it is low, near knee height; in the second it is higher, near his waist. Draw NO ball in any frame.",
"palette_ref": "o_idle1"
},
"marvin": {
"sprites": [
"marvin1",
"marvin2",
"marvin3",
"marvin4"
],
"ref_only": [
"x_boymop1"
],
"anchor": 0,
"check": "loose",
"subject": "The reference is ONE young ball boy in a WHITE polo shirt and WHITE shorts with brown hair, seen from the FRONT, holding a floor mop. Draw FOUR frames of that SAME boy \u2014 same face, same brown hair, same white kit, same white shoes, same size, same front-facing view \u2014 with a gormless, dim-witted, slightly cross-eyed expression in every frame. THE FOUR FRAMES ARE FOUR DIFFERENT POSES: (1) WAVING hello with his free hand raised beside his head, big goofy open-mouthed grin, the mop standing upright in his other hand; (2) POINTING away to the side with one arm stuck straight out and his mouth open explaining something, mop upright in the other hand; (3) SHRUGGING with both palms turned up and his shoulders hunched, utterly baffled, eyebrows up, the mop tucked under one arm; (4) TERRIFIED \u2014 eyes wide as saucers, mouth open in a yell, both hands clutching the mop up across his chest like a weapon. Each figure stands upright at the same height with both feet on the ground. The whole boy and the whole mop are fully inside the canvas with clear space around them. The canvas contains EXACTLY FOUR figures and no others."
},
"ballboy_walk": {
"sprites": [
"z_e1",
@@ -924,4 +910,4 @@
"anchor": 0,
"subject": "Two frames of the woman in the reference \u2014 same DARK ALMOST-BLACK ponytail, same white shirt, same white pleated tennis skirt. She is seen strictly FROM BEHIND, the back of her head and her ponytail towards the viewer, NO face, from exactly the reference's viewpoint. The racket stays in her RIGHT hand (the RIGHT-HAND SIDE OF THE IMAGE) in both frames; her empty LEFT hand is on the LEFT-HAND SIDE OF THE IMAGE. Draw NO ball. She is patting a ball against the ground before serving, so her empty LEFT hand stays LOW in BOTH frames \u2014 it NEVER rises above her waist and NEVER comes near her shoulder or her head. Frame 1: she leans forward slightly and her LEFT arm reaches DOWN and out to the left, the palm turned down at about KNEE height. Frame 2: the SAME stance with that same LEFT hand lifted only a little, the palm still turned down, now at about HIP height. Frames 1 and 2 must be the same drawing with ONLY the left hand's height changed \u2014 identical legs, identical body, identical racket, identical height."
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

+27
View File
@@ -473,6 +473,29 @@ export const BOSS_ART = [
volley: 5, gap: 0, spread: 1.6, cycle: 3.2, wind: 0.65, shot: 14 }, // there is nothing to parry
];
// The roster, as the BODY COUNT screen reads it. Until now a variant's NAME lived only in the
// comment above its hp value — fine while nothing ever had to print one. The key is the `variant`
// char the zombie already carries, except that 'a' and 'b' fold together (they are one shambler in
// two shirts, and two identical rows on the stats list would just look like a bug) and a chapter
// boss files under 'B0'..'B5' by BOSS_ART index, so the screen can name the design that got you.
// `spr` is the frame the row draws — the first walk frame, which every body has.
// The barrel ('o') is deliberately absent: it is loot, not an opponent, and killZombie scores it
// as nothing. Keep that agreement — a barrel row would put a crate in a list of the dead.
export const ENEMIES = {
a: { name: 'SHAMBLER', spr: 'z_a1', sub: 'THE ORDINARY DEAD' },
d: { name: 'THE STEWARD', spr: 'z_d1', sub: 'HI-VIS · ONE HIT TOUGHER' },
e: { name: 'THE BALL BOY', spr: 'z_e1', sub: 'STILL FETCHING' },
f: { name: 'THE RUNNER', spr: 'z_f1', sub: 'ONE HIT · BUT HE SPRINTS' },
g: { name: 'THE BRUTE', spr: 'z_g1', sub: 'SLOW · DRAGS A POLE' },
h: { name: 'THE BOUNCER', spr: 'z_h1', sub: 'BUILT LIKE A DOOR' },
k: { name: 'THE ROLLER MAN', spr: 'z_k1', sub: 'HAULS THE COURT ROLLER' },
s: { name: 'THE SPECTATOR', spr: 'z_s1', sub: 'BLOCKS WITH HIS CHAIR' },
r: { name: 'THE CLUB PRO', spr: 'z_c1', sub: 'STILL HAS A BACKHAND' },
m: { name: 'THE BALL MACHINE', spr: 'z_mach1', sub: 'FIRES BACK · FURNITURE' },
};
for (let i = 0; i < BOSS_ART.length; i++) // the six chapter bosses, named where they already are
ENEMIES['B' + i] = { name: BOSS_ART[i].name, spr: BOSS_ART[i].walk[0], sub: 'CHAPTER BOSS', boss: true };
// Which wound stage a body is in: 0 = unhurt, 1..n = the skins, spread evenly over its full health.
// This is the rule the chapter boss already used, and it turns out to reproduce the brute's old
// hardcoded thresholds EXACTLY (5 hp over 4 skins = one skin per hit) and the shamblers' single skin
@@ -551,6 +574,10 @@ export const WEAPONS = {
col: '#ffd050', glow: 1.8,
desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] },
};
// Every row knows its own key. The rack has always looked weapons up BY key and passed the row
// around after that, so nothing needed the id — the stats screen does, because a kill has to be
// filed under the weapon that made it long after the row itself is out of scope.
for (const k in WEAPONS) WEAPONS[k].id = k;
// What a pink barrel can be carrying — the four above, which you cannot win any other way. NOT a
// flat 1-in-4: on that, Oliver "got the laser almost every time" and never once saw the beacon.
// Weighted, gated, and it never hands you the same thing twice running (see pickBarrelWeapon).
+4 -3
View File
@@ -143,7 +143,7 @@ function doHit(ch, quality, aim) {
G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return
ball.pow = quality; // carried pace: zombies splatter only above zSplatQ
ball.kills = 0; // fresh shot: multi-kill streak restarts
ball.bomb = false; ball.flame = false; // only a consuming serve arms these; any other hit clears them
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
// TENNIS ONLY. It answers "did that land in?", and splatter does not ask: `out` could never be
// true there, so it was a permanent yellow ring on the grass carrying no information — and
@@ -284,6 +284,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
// rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve.
const wsel = G.mode === 'splatter' ? G.weapon : 0;
const wid = wsel === 0 && !G.temp ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0;
ball.wid = armed ? wid : 'ball'; // an unstocked tin throws a plain ball — file the kills that way
// THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else
// about the gesture is untouched — the pull, the aim, the release — which is the whole point of it.
if (wid === 'laser' && armed) {
@@ -304,7 +305,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
// g: ball.g — a serve carries up to 2.3x gravity in topspin; clones integrated at flat
// TUNE.gravity floated long while the centre ball dived, which is the "wings lob off at
// the wrong height" report. They are the SAME shot, fanned.
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, g: ball.g,
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, g: ball.g, wid: 'triple',
vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c });
}
}
@@ -1076,7 +1077,7 @@ export function dragServe(aim) {
// rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that
// walked the player around between shots was the confusing part, not the fire rate.
if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g });
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g, wid: ball.wid });
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall
// a full pull is quality 1.0, but only a pull that STOPS at the top of the draw is praised
+211 -100
View File
@@ -63,98 +63,188 @@
#hint { position: fixed; top: 6px; right: 10px; text-align: right;
font: 400 10px Silkscreen, monospace; color: #6b7688; }
@media (pointer: coarse) { #hint { display: none; } }
#intro { position: fixed; inset: 0; z-index: 10; touch-action: none; display: grid; place-items: center;
background: radial-gradient(circle at 50% 34%, rgba(34,16,20,0.92), rgba(8,10,16,0.96)); }
#intro .card { font: 400 11px Silkscreen, monospace; color: #dfe6f2; text-align: center; line-height: 1.9;
padding: 18px 26px; max-width: 92vw; animation: drop 0.5s ease-out; }
/* ---- THE MENU PALETTE. The canvas HUD keeps its own blue-grey and gold; the menus are
deliberately a different room — dark court green, one acid accent, and one crimson reserved for
the thing you actually came here to press. Five names so a repaint is five edits, not fifty. */
:root { --ink:#0d1408; --panel:rgba(30,45,18,0.55); --line:#3e5a26; --acid:#c8e04a;
--cream:#dfe6c9; --dim:#7f9160; --red:#b3201c; --bad:#e0564a;
/* the same green as --panel over --ink, but OPAQUE: the pinned header and footer have
content scrolling underneath them and a translucent bar shows it through */
--solid:#161f0d; }
/* touch-action is pan-y, not none: the BODY COUNT list is taller than a phone and has to scroll.
align-content is `safe center` rather than place-items — plain centring on a grid whose item
overflows pushes the top of the item out of the scrollport, where nothing can reach it. */
#intro { position: fixed; inset: 0; z-index: 10; touch-action: pan-y; background: var(--ink);
display: grid; grid-template-columns: 1fr; justify-items: center;
align-content: safe center; overflow-y: auto; overscroll-behavior: contain; }
/* the SPLASH sits on the court; the other stages are lists and want a flat field behind them.
The art is dimmed here rather than baked dark, so the same file can be reused at another level. */
#intro[data-stage="splash"] {
background: linear-gradient(rgba(9,14,7,0.55), rgba(9,14,7,0.88)),
url('assets/menu_bg.png?v=85') 50% 46% / cover no-repeat var(--ink);
image-rendering: pixelated; }
#intro .card { font: 400 11px Silkscreen, monospace; color: var(--cream); text-align: center;
line-height: 1.9; padding: 16px 14px 20px; width: min(370px, 94vw);
animation: drop 0.5s ease-out; }
@keyframes drop { from { transform: translateY(-14px); opacity: 0; } }
/* the logo is generated pixel art now (assets/logo.png), not stacked CSS text */
#intro .logo { display: block; width: min(320px, 78vw); height: auto; margin: 0 auto 8px;
/* EVERY SCREEN FILLS THE VIEWPORT, and every screen is the same three bands: something pinned to
the top, something pinned to the bottom, and whatever is between them taking the slack. The
card is a full-height flex column, so `margin-top:auto` on the footer drops it to the bottom
when the content is short and `position:sticky` keeps it there once the content is long enough
to scroll. Centring the card instead left the buttons floating in the middle of a tall phone. */
#intro { align-content: stretch; }
#intro .card { display: flex; flex-direction: column; min-height: 100dvh; padding: 0 14px; }
/* the slack band: the title sits in the middle of whatever is left over */
.mid { flex: 1; display: flex; flex-direction: column; justify-content: center; padding: 12px 0; }
/* the logo is generated pixel art (assets/logo.png), not stacked CSS text */
#intro .logo { display: block; width: min(300px, 74vw); height: auto; margin: 0 auto 6px;
image-rendering: pixelated; filter: drop-shadow(0 3px 0 rgba(0,0,0,0.45)); }
/* the slogan carries most of the gap between the logo's blood and the first button */
#intro .slogan { margin: 10px 0 26px; font-size: 12px; color: #b6a0a4; letter-spacing: 0.5px; }
#intro .slogan { margin: 8px 0 20px; font-size: 11px; color: #b6a0a4; letter-spacing: 0.5px; }
#intro .slogan b { color: #c9564a; }
/* which build is served — filled in by main.js, empty (and invisible) if the header is missing */
#build { margin: 10px 0 0; font-size: 9px; color: #5a6270; letter-spacing: 0.5px; }
#build { margin: 14px 0 0; font-size: 9px; color: #4d5a3c; letter-spacing: 0.5px; }
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #courts, #tutorial { display: none; }
#intro[data-stage="splash"] #splash { display: block; }
#intro[data-stage="courts"] #courts { display: block; animation: drop 0.3s ease-out; }
#intro[data-stage="tutorial"] #tutorial { display: block; animation: drop 0.3s ease-out; }
#splash, #setup, #stats, #ranks { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */
#intro[data-stage="setup"] #setup,
#intro[data-stage="stats"] #stats,
#intro[data-stage="ranks"] #ranks { display: flex; flex-direction: column; flex: 1;
animation: drop 0.3s ease-out; }
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
display:grid above would otherwise win over the hidden attribute */
#intro[hidden] { display: none; }
/* headline: the ANNOUNCEMENT face, the same register the canvas uses for SET and CHAPTER — a
screen title is a thing that shouts, not a line of HUD. Bangers carries its own weight, so it
wants size and air rather than Silkscreen's letter-spacing. */
#intro h2 { margin: 0 0 26px; font: 400 30px Bangers, cursive; color: #e0cc72;
letter-spacing: 2px; line-height: 1.2; padding-bottom: 6px; }
/* Screen titles and the primary button are BANGERS — the same announcement face the canvas uses
for SET and CHAPTER. A screen title is a thing that shouts. Everything that is a NUMBER or a
label stays Silkscreen, so the two registers keep meaning what they have always meant. */
#intro h2 { margin: 0; font: 400 26px Bangers, cursive; color: var(--acid); letter-spacing: 1.5px; }
#intro h3 { display: flex; justify-content: space-between; margin: 18px 0 8px;
font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px;
text-align: left; }
/* header strip: back chevron + title. Every screen but the splash wears one, pinned to the top. */
.hdr { position: sticky; top: 0; z-index: 3; display: flex; align-items: center; gap: 10px;
padding: 10px 12px; margin: 0 -14px; border-bottom: 2px solid var(--line);
background: var(--solid);
padding-top: max(10px, env(safe-area-inset-top)); }
.hdr h2 { padding-left: 2px; }
.chev { width: 34px; height: 34px; flex: none; background: none; border: 2px solid var(--line);
color: var(--acid); font: 400 20px Bangers, cursive; line-height: 1; cursor: pointer;
-webkit-tap-highlight-color: transparent; }
/* the splash stat bar and the BODY COUNT headline row: boxed figures sharing one frame */
.bar, .figs { display: flex; border: 2px solid var(--line); background: var(--panel); }
/* the bar is the splash's top band — pinned like a header, and the safe-area inset is its job
because nothing else on that screen sits above it */
.bar { flex: none; margin-top: max(12px, env(safe-area-inset-top)); }
.bar .fig { padding: 6px 10px; text-align: left; }
.figs .fig { padding: 7px 6px; text-align: center; }
.fig { flex: 1; min-width: 0; }
.bar .fig + .fig { text-align: right; }
.fig + .fig { border-left: 2px solid var(--line); }
.fig b { display: block; font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.fig i { display: block; font: 700 15px Silkscreen, monospace; color: var(--acid);
font-style: normal; letter-spacing: 1px; }
.fig.bad i { color: var(--bad); }
/* one button look for every menu screen */
.btn { display: block; width: min(340px, 84vw); margin: 0 auto 10px; padding: 12px 10px;
background: rgba(255,255,255,0.04); border: 2px solid #3b4354; color: #dfe6f2;
.btn { display: block; width: 100%; margin: 0 auto 10px; padding: 12px 10px;
background: var(--panel); border: 2px solid var(--line); color: var(--cream);
font: 400 12px Silkscreen, monospace; letter-spacing: 1px; cursor: pointer;
-webkit-tap-highlight-color: transparent; }
.btn.go { border-color: #e0cc72; color: #e0cc72; }
/* The two menu buttons wear generated plates. The LETTERING stays live text on top rather than
being drawn into the art: generated type is a lottery and would not match the rest of the HUD.
A text-shadow is what keeps it readable where a drip runs behind it. The border is CSS, not
art — the plate crops are interior-only, so both buttons get the same clean frame. */
.btn.plate, .btn.plate2 { padding: 15px 10px; font-size: 15px; letter-spacing: 2px;
background-size: 100% 100%; background-repeat: no-repeat;
image-rendering: pixelated; text-shadow: 0 2px 0 rgba(0,0,0,0.9); }
/* No gold frame on START any more — the sweep below is what draws the eye, and the two buttons
now share one border so the pair reads as a pair. */
.btn.plate { background-image: url('assets/btn_start.png?v=94'); color: #f7ecb8;
border-color: #3b4354; position: relative; overflow: hidden; }
/* a light passing over the sign: across in ~0.8s, then still for the rest of the cycle.
pointer-events:none so the sweep can never eat the tap. */
.btn.plate::after { content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(105deg, transparent 42%, rgba(255,255,255,0.34) 50%,
transparent 58%);
transform: translateX(-140%); animation: shine 4.5s ease-in-out infinite; }
@keyframes shine { 0% { transform: translateX(-140%); } 18%, 100% { transform: translateX(140%); } }
@media (prefers-reduced-motion: reduce) { .btn.plate::after { animation: none; opacity: 0; } }
.btn.plate2 { background-image: url('assets/btn_tut.png?v=94'); color: #d6dfec; }
.btn.back { font-size: 10px; padding: 9px 10px; color: #8a96ab; }
/* The generated button plates are gone with the tutorial. The primary is a flat crimson block
now: it survives being resized, which a fixed-size plate crop never did, and the mockup's
hierarchy is carried by colour rather than by art. */
.btn.big { padding: 13px 10px 11px; background: var(--red); border-color: #6d1310; color: #f6e7dc;
font: 400 26px Bangers, cursive; letter-spacing: 2px; line-height: 1.15; }
.two { display: flex; gap: 8px; }
.two .btn { margin-bottom: 0; }
.btn.sec i { display: block; margin-top: 3px; font: 400 8px Silkscreen, monospace;
font-style: normal; color: var(--dim); letter-spacing: 0.5px; }
/* the tour: two tiles SIDE BY SIDE, not two more full-width rows. The figures face the camera —
in play you only ever see your player from behind, so a rear view would show nothing. */
#tours { display: flex; gap: 8px; }
#tours button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 8px 6px 10px; background: var(--panel); border: 2px solid var(--line);
color: inherit; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
#tours button.on { border-color: var(--acid); background: rgba(200,224,74,0.10); }
#tours img { height: 88px; width: auto; display: block; image-rendering: pixelated; }
#tours b { font: 400 10px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
/* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still
reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */
#courts button.court { display: flex; align-items: center; gap: 12px; width: min(340px, 84vw);
margin: 0 auto 8px; padding: 6px; background: rgba(255,255,255,0.04);
border: 2px solid #3b4354; color: inherit; font: inherit; text-align: left;
#courtList button.court { display: flex; align-items: center; gap: 11px; width: 100%;
margin: 0 0 8px; padding: 7px; background: var(--panel);
border: 2px solid var(--line); color: inherit; font: inherit; text-align: left;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#courts button.court.on { border-color: #e0cc72; background: rgba(224,204,114,0.10); }
/* scoped to #courtList, not #courts: the tour tiles above are portrait figures and must not
inherit the 108x72 court-thumbnail box */
#courtList img { width: 108px; height: 72px; display: block; image-rendering: pixelated; flex: none; }
#courtList b { display: block; font: 400 11px Silkscreen, monospace; color: #dfe6f2; letter-spacing: 0.5px; }
#courtList i { display: block; margin-top: 5px; font: 400 9px Silkscreen, monospace; color: #8a96ab; font-style: normal; }
#courts .acts { margin-top: 16px; }
/* the tour: two tiles SIDE BY SIDE, not two more full-width rows. The court screen already carries
three rows and a pair of buttons, and a phone has no room left. The figures face the camera —
in play you only ever see your player from behind, so a rear view would show nothing. */
#tours { display: flex; gap: 8px; width: min(340px, 84vw); margin: 0 auto 10px; }
#tours button { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 8px 6px 10px; background: rgba(255,255,255,0.04); border: 2px solid #3b4354;
color: inherit; font: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent; }
#tours button.on { border-color: #e0cc72; background: rgba(224,204,114,0.10); }
#tours img { height: 92px; width: auto; display: block; image-rendering: pixelated; }
#tours b { font: 400 10px Silkscreen, monospace; color: #dfe6f2; letter-spacing: 0.5px; }
/* tutorial: Marvin on the left, his line and the picture on the right */
#tutorial .page { display: flex; align-items: flex-end; gap: 14px; width: min(360px, 88vw);
margin: 0 auto; text-align: left; min-height: 190px; }
#tutorial .marvin { width: 84px; flex: none; image-rendering: pixelated; align-self: flex-end; }
#tutorial .say { flex: 1; }
#tutorial .say p { margin: 0 0 10px; font: 400 10px Silkscreen, monospace; color: #dfe6f2; line-height: 1.9; }
#tutorial .say em { color: #e0cc72; font-style: normal; }
/* the panel is 2:1 and it is the point of the page, so it gets the full card width above the
Marvin row rather than being squeezed into the text column beside him */
#tutorial .shot { display: block; width: min(360px, 88vw); margin: 0 auto 12px;
image-rendering: pixelated; border: 2px solid #2a3140; }
#tutorial .dots { margin: 14px 0 10px; font: 400 10px Silkscreen, monospace; color: #5a6270; letter-spacing: 3px; }
#tutorial .acts { display: flex; gap: 8px; width: min(340px, 84vw); margin: 0 auto; }
#tutorial .acts .btn { margin: 0; flex: 1; }
#courtList button.court.on { border-color: var(--acid); background: rgba(200,224,74,0.10); }
#courtList img { width: 92px; height: 66px; display: block; image-rendering: pixelated; flex: none;
object-fit: cover; }
#courtList .cb { flex: 1; min-width: 0; }
#courtList b { display: block; font: 400 11px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px; }
#courtList i { display: block; margin: 2px 0 5px; font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal; }
/* The two meters read straight off the court's own bounce numbers (see main.js), so they cannot
drift away from what the ball actually does on it. */
.meter { display: flex; align-items: center; gap: 6px; margin-top: 3px;
font: 400 7px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.meter u { flex: none; width: 40px; text-decoration: none; }
.meter s { flex: 1; height: 5px; background: rgba(0,0,0,0.45); text-decoration: none; display: block; }
.meter s > i { display: block; height: 100%; background: var(--acid); }
.meter.b s > i { background: #5aa9d0; }
/* the commit strip. #intro is the scrollport, so sticky resolves against it and the button stays
reachable however long the list above grows. */
/* margin-top:auto pins it to the BOTTOM of a short screen; sticky keeps it there on a long one */
.foot { position: sticky; bottom: 0; z-index: 2; margin: auto -14px 0; padding: 10px 14px;
padding-bottom: max(10px, env(safe-area-inset-bottom));
border-top: 2px solid var(--line); background: var(--solid); }
.foot .btn { margin-bottom: 0; }
/* the splash's bottom band: no bar, no rule — the court art has to keep running under it */
.foot.bare { border: 0; background: none; }
.foot.bare .btn.big { margin-bottom: 10px; }
#sSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px;
font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.tabs { display: flex; gap: 6px; margin: 12px 0 14px; }
.tabs button { flex: 1; padding: 9px 2px; background: var(--panel); border: 2px solid var(--line);
color: var(--dim); font: 400 8px Silkscreen, monospace; letter-spacing: 1px;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
.tabs button.on { border-color: var(--acid); color: var(--acid); }
/* one opponent. The sprite is the walk frame the game already draws, sat on its own baseline. */
.zrow { display: flex; align-items: center; gap: 10px; padding: 7px 9px; margin-bottom: 6px;
border: 2px solid var(--line); background: var(--panel); text-align: left; }
.zrow.boss { border-color: #7d2420; background: rgba(120,26,22,0.28); }
.zrow img { width: 34px; height: 46px; object-fit: contain; object-position: bottom;
image-rendering: pixelated; flex: none; }
.zb { flex: 1; min-width: 0; display: grid; grid-template-columns: 1fr auto; gap: 0 8px;
line-height: 1.4; }
.zb b { font: 400 10px Silkscreen, monospace; color: var(--cream); letter-spacing: 0.5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zb > span { font: 700 12px Silkscreen, monospace; color: var(--acid); text-align: right; }
.zrow.boss .zb > span { color: var(--bad); }
.pb { grid-column: 1 / -1; height: 5px; margin-top: 4px; background: rgba(0,0,0,0.45); }
.pb s { display: block; height: 100%; background: var(--acid); text-decoration: none; }
.zrow.boss .pb s { background: var(--bad); }
.zb i { grid-column: 1 / -1; margin-top: 3px; font: 400 7px Silkscreen, monospace;
font-style: normal; color: var(--dim); letter-spacing: 1px; }
#stW { display: flex; flex-wrap: wrap; gap: 6px; }
.wtile { flex: 1 1 54px; max-width: 76px; padding: 6px 2px; border: 2px solid var(--line);
background: var(--panel); }
.wtile img { width: 26px; height: 26px; object-fit: contain; display: block; margin: 0 auto 2px;
image-rendering: pixelated; }
.wtile b { display: block; font: 400 9px Silkscreen, monospace; color: var(--acid); }
.none { margin: 4px 0 0; font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px;
text-align: left; }
#stFoot { display: flex; justify-content: space-between; align-items: baseline;
border-top: 2px solid var(--line); }
#stFoot b { font: 400 8px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
#stFoot i { font: 700 13px Silkscreen, monospace; color: var(--acid); font-style: normal; }
.rkrow { display: flex; align-items: center; gap: 10px; padding: 8px 10px; margin-bottom: 5px;
border: 2px solid var(--line); background: var(--panel); text-align: left; }
.rkrow.me { border-color: var(--acid); background: rgba(200,224,74,0.12); }
.rkrow b { flex: none; width: 60px; font: 700 10px Silkscreen, monospace; color: var(--dim); }
.rkrow.me b { color: var(--acid); }
.rkrow span { flex: 1; min-width: 0; font: 400 9px Silkscreen, monospace; color: var(--cream);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rkrow i { font: 400 9px Silkscreen, monospace; font-style: normal; color: var(--dim); }
</style>
</head>
<body>
@@ -164,42 +254,63 @@
<div id="intro" data-stage="splash">
<div class="card">
<!-- The title screen IS the menu. A separate menu stage behind it was the same logo over the
same backdrop with two buttons, so it read as the title screen failing to advance. The
control list that used to sit here is the tutorial's job now. -->
same backdrop with two buttons, so it read as the title screen failing to advance. -->
<!-- three bands: the figures at the top, the title floating in the middle, the buttons under
your thumb. Same anchoring as every other screen. -->
<div id="splash">
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
<button class="btn plate" id="mStart">START GAME</button>
<button class="btn plate2" id="mTut">TUTORIAL</button>
<p id="build"></p>
<div class="bar">
<div class="fig"><b>BEST RUN</b><i id="sBest">—</i></div>
<div class="fig"><b>WORLD RANK</b><i id="sRank">—</i></div>
</div>
<div class="mid">
<img class="logo" src="assets/logo.png" alt="Splatter Tennis">
<p class="slogan">Fifteen. Thirty. Forty. <b>Dead.</b></p>
</div>
<div class="foot bare">
<button class="btn big" id="mStart">PLAY MATCH</button>
<div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
<button class="btn sec" id="mRanks">RANKINGS<i>SEASON 1</i></button>
</div>
<p id="build"></p>
</div>
</div>
<!-- tour tiles and court rows are both built from tables in main.js (config.TOURS / config.COURTS):
adding either is a table row, never markup -->
<div id="courts">
<h2>CHOOSE YOUR MATCH</h2>
<div id="setup">
<div class="hdr"><button class="chev" id="sBack">&lsaquo;</button><h2>MATCH SETUP</h2></div>
<h3>01 · PLAYER</h3>
<div id="tours"></div>
<h3>02 · COURT</h3>
<div id="courtList"></div>
<div class="acts">
<button class="btn go" id="cStart">START GAME</button>
<button class="btn back" id="cBack">BACK</button>
<div class="foot">
<p id="sSum"></p>
<button class="btn big" id="cStart">SERVE UP</button>
</div>
</div>
<!-- tutorial pages are built from a table in main.js -->
<div id="tutorial">
<h2>HOW IT WORKS</h2>
<img class="shot" id="tShot" alt="">
<div class="page">
<img class="marvin" id="tMarvin" src="assets/marvin1.png" alt="">
<div class="say" id="tSay"></div>
</div>
<p class="dots" id="tDots"></p>
<div class="acts">
<button class="btn back" id="tBack">BACK</button>
<button class="btn go" id="tNext">NEXT</button>
<!-- BODY COUNT. Every row in here is built by stats.js from the run records in localStorage. -->
<div id="stats">
<div class="hdr"><button class="chev" id="stBack">&lsaquo;</button><h2>BODY COUNT</h2></div>
<div class="tabs" id="stTabs">
<button class="on" data-p="all">ALL TIME</button>
<button data-p="last">LAST MATCH</button>
<button data-p="7d">7 DAYS</button>
</div>
<div class="figs" id="stFigs"></div>
<h3>BY OPPONENT<span id="stPeriod"></span></h3>
<div id="stZ"></div>
<h3>BY WEAPON</h3>
<div id="stW"></div>
<div class="foot" id="stFoot"></div>
</div>
<!-- placeholder ladder until the leaderboard task lands — see stats.drawRanks -->
<div id="ranks">
<div class="hdr"><button class="chev" id="rkBack">&lsaquo;</button><h2>RANKINGS</h2></div>
<h3>SEASON 1<span>UNRANKED · OFFLINE</span></h3>
<div id="rkList"></div>
</div>
</div>
</div>
<script type="module" src="main.js?v=121"></script>
<script type="module" src="main.js?v=122"></script>
</body>
</html>
+74 -69
View File
@@ -6,10 +6,11 @@
import { TUNE, COURT, COURTS, TOURS, UMP_H, UMP_X, EATEN_RESTART } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight , toggleTestMode } from './gameplay.js';
import { fitViewport, render, proj, RES } from './render.js';
import { fitViewport, render, proj, RES, SPRV } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
import { cv } from './dom.js';
import * as stats from './stats.js';
import './input.js';
let last = performance.now();
@@ -45,9 +46,9 @@ setupPoint();
if (document.fonts) for (const f of ['700 8px Silkscreen', '400 8px Silkscreen', '400 20px Bangers'])
document.fonts.load(f);
// The overlay is a three-stage machine: splash -> {courts, tutorial}. The title screen IS the menu —
// a separate menu stage behind it showed the same logo on the same backdrop and read as the title
// screen failing to advance. One overlay throughout, swapped by data-stage, so the court never
// The overlay is a four-stage machine: splash -> {setup, stats, ranks}. The title screen IS the
// menu — a separate menu stage behind it showed the same logo on the same backdrop and read as the
// title screen failing to advance. One overlay throughout, swapped by data-stage, so the court never
// flashes between screens; HIDDEN during play rather than removed, so a restart puts it back.
const intro = document.getElementById('intro');
const menuUp = () => !intro.hidden;
@@ -56,35 +57,70 @@ 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
// --- tour tiles, built from the table. Same "tap selects, START starts" rule as the court rows —
// the court screen picks WHO and WHERE, and nothing on it commits you.
// --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows —
// the setup screen picks WHO and WHERE, and nothing on it commits you.
const tourList = document.getElementById('tours');
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.innerHTML = `<img src="assets/${t.tile}.png?v=84" alt=""><b>${t.name}</b>`;
b.innerHTML = `<img src="assets/${t.tile}.png?v=${SPRV}" alt=""><b>${t.name}</b>`;
b.addEventListener('click', () => {
pickedTour = t.id;
for (const el of tourList.children) el.classList.toggle('on', el === b);
summary();
});
tourList.appendChild(b);
}
// --- court rows, built from the table
// --- 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
// that plays differently from what it says.
// SPEED how much pace survives the bounce — bounceXZ, grass .84 fastest, clay .70 slowest
// BOUNCE how much of it comes back UP — bounceY, clay .66 highest, grass .50 lowest
// The two divisors just stretch those narrow real ranges across a full bar; they carry no physics.
const meter = (label, v, cls = '') =>
`<div class="meter ${cls}"><u>${label}</u><s><i style="width:${
Math.round(Math.min(1, Math.max(0.08, v)) * 100)}%"></i></s></div>`;
const courtList = document.getElementById('courtList');
for (const c of COURTS) {
const b = document.createElement('button');
b.className = 'court' + (c.id === picked ? ' on' : ''); // your last court comes up pre-picked
b.innerHTML = `<img src="assets/${c.tile}.png?v=84" alt=""><span><b>${c.name}</b><i>${c.sub}</i></span>`;
b.innerHTML = `<img src="assets/${c.tile}.png?v=${SPRV}" alt=""><span class="cb">`
+ `<b>${c.name}</b><i>${c.sub}</i>`
+ meter('SPEED', (c.bounceXZ - 0.62) / 0.26)
+ meter('BOUNCE', (c.bounceY - 0.44) / 0.28, 'b')
+ '</span>';
// Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave
// you no way to look at the three of them and no way back out of the screen.
b.addEventListener('click', () => {
picked = c.id;
for (const el of courtList.children) el.classList.toggle('on', el === b);
summary();
});
courtList.appendChild(b);
}
// what the setup footer says you are about to play, and what it is worth beating
function summary() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const c = COURTS.find(x => x.id === picked) || COURTS[0];
const best = stats.total().best;
document.getElementById('sSum').innerHTML =
`<span>${t.name.replace(' SINGLES', '')} · ${c.name}</span>`
+ `<span>${best ? best + (best === 1 ? ' GAME' : ' GAMES') + ' TO BEAT' : 'FIRST RUN'}</span>`;
}
// the splash figures. Re-read every time the splash comes up, because a run has just ended and
// both numbers have moved.
function refreshSplash() {
const { kills, best } = stats.total();
document.getElementById('sBest').textContent = best ? best + (best === 1 ? ' GAME' : ' GAMES') : '—';
document.getElementById('sRank').textContent = '#' + stats.rank(kills).toLocaleString('en-GB');
document.getElementById('mKills').textContent = kills.toLocaleString('en-GB') + ' KILLS';
summary();
}
function start() {
if (!menuUp()) return;
selectTour(pickedTour);
@@ -92,62 +128,22 @@ function start() {
menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience
intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture
}
document.getElementById('mStart').addEventListener('click', () => go('courts'));
document.getElementById('mTut').addEventListener('click', () => { tutPage = 0; drawTut(); go('tutorial'); });
let stPeriod = 'all';
const openStats = () => { stats.drawStats(stPeriod, SPRV); go('stats'); };
document.getElementById('mStart').addEventListener('click', () => go('setup'));
document.getElementById('mStats').addEventListener('click', openStats);
document.getElementById('mRanks').addEventListener('click', () => { stats.drawRanks(); go('ranks'); });
document.getElementById('cStart').addEventListener('click', start);
document.getElementById('cBack').addEventListener('click', () => go('splash'));
// --- the tutorial, narrated by Marvin the mop boy. Pure data: a page is a Marvin pose, an optional
// picture already in assets/, and his lines.
const TUT = [
{ pose: 1, say: ["I'm <em>MARVIN</em>. I mop the court.",
"Lately there is a great deal more to mop.",
"Let me show you how this goes."] },
{ pose: 2, shot: 'tut_serve', say: ["First it is just <em>TENNIS</em>.",
"<em>TAP</em> once to toss, tap again to serve.",
"<em>FLICK</em> toward where you want it.",
"He runs down the rest himself, mostly."] },
{ pose: 4, say: ["Then, at some point, the other fellow...",
"stops being the other fellow.",
"Nobody stops the match. Nobody ever does."] },
{ pose: 2, shot: 'tut_aim', say: ["Now you do not rally. You <em>AIM</em>.",
"<em>PULL BACK</em> from your man and let go.",
"The further you pull, the harder it lands.",
"A soft one only makes them cross."] },
{ pose: 4, say: ["You will not outrun them. Don't try.",
"<em>SWIPE SIDEWAYS</em> and he plants a foot",
"and is simply somewhere else.",
"Nothing touches him on the way. Nothing."] },
{ pose: 2, shot: 'tut_rack', say: ["I bring tins out to the rack down there.",
"<em>BOMBS</em>, <em>TRIPLES</em>, <em>FIRE</em>, and the <em>GUN</em>.",
"Tap the rack to load one, then serve it.",
"Do try not to hit me with them."] },
{ pose: 3, shot: 'tut_duel', say: ["Sometimes the umpire calls order back.",
"One point, tennis rules, against one of them.",
"The lines are live again. Do not miss."] },
{ pose: 1, say: ["That is the lot. Mind the deep ones.",
"And if it all goes wrong —",
"I will be along with the mop."] },
];
let tutPage = 0;
const tSay = document.getElementById('tSay'), tMarvin = document.getElementById('tMarvin');
const tShot = document.getElementById('tShot');
const tDots = document.getElementById('tDots'), tNext = document.getElementById('tNext');
function drawTut() {
const p = TUT[tutPage];
tMarvin.src = `assets/marvin${p.pose}.png?v=92`;
tShot.style.display = p.shot ? 'block' : 'none';
if (p.shot) tShot.src = `assets/${p.shot}.png?v=93`;
tSay.innerHTML = p.say.map(l => `<p>${l}</p>`).join('');
tDots.textContent = TUT.map((_, i) => i === tutPage ? '●' : '·').join('');
tNext.textContent = tutPage === TUT.length - 1 ? 'DONE' : 'NEXT';
}
tNext.addEventListener('click', () => {
if (tutPage < TUT.length - 1) { tutPage++; drawTut(); } else go('splash');
});
document.getElementById('tBack').addEventListener('click', () => {
if (tutPage > 0) { tutPage--; drawTut(); } else go('splash');
for (const id of ['sBack', 'stBack', 'rkBack'])
document.getElementById(id).addEventListener('click', () => go('splash'));
// the three period tabs are one listener on the strip — the button carries the period in data-p
document.getElementById('stTabs').addEventListener('click', e => {
const b = e.target.closest('button'); if (!b) return;
stPeriod = b.dataset.p;
for (const el of e.currentTarget.children) el.classList.toggle('on', el === b);
stats.drawStats(stPeriod, SPRV);
});
refreshSplash();
// THE FIRST TAP ANYWHERE ON THE OVERLAY STARTS THE THEME, and does nothing else — the screens have
// real buttons now. This is the gesture the autoplay policy is waiting for, which is why the theme
@@ -161,24 +157,32 @@ fetch('main.js', { method: 'HEAD' })
.then(r => { const t = r.headers.get('last-modified'); if (!t) return;
document.getElementById('build').textContent = 'BUILD ' + new Date(t).toLocaleString('en-GB').replace(',', ''); })
.catch(() => {}); // no header, no line — never a broken start screen
// Keyboard, per stage. On the splash Enter/Space starts, T opens the tutorial; on the court screen
// 1..N picks and Enter starts; Escape backs out to the splash.
// Keyboard, per stage. On the splash Enter/Space goes to setup and S opens the stats; on the setup
// screen 1..N picks a court and Enter starts; Escape backs out to the splash from anywhere.
addEventListener('keydown', e => {
if (!menuUp()) return;
menuMusic(); // a key is a gesture too — unlock the theme on a desktop that never taps
const stage = intro.dataset.stage;
if (e.key === 'Escape') return go('splash');
if (stage === 'splash') {
if (e.key === 'Enter' || e.key === ' ') go('courts');
else if (e.key === 't' || e.key === 'T') { tutPage = 0; drawTut(); go('tutorial'); }
if (e.key === 'Enter' || e.key === ' ') go('setup');
else if (e.key === 's' || e.key === 'S') openStats();
return;
}
if (stage === 'tutorial') { if (e.key === 'Enter' || e.key === ' ') tNext.click(); return; }
if (stage !== 'setup') return; // stats and ranks are read-only: Escape is the whole interface
const n = parseInt(e.key, 10);
if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click();
else if (e.key === 'Enter' || e.key === ' ') start();
});
// A run the player walks out on is still a run. killPlayer banks a death; this banks everything
// else — the tab closed mid-match, the phone's browser killed in the background. `pagehide` is the
// 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', () => {
if (G.mode === 'splatter') stats.endRun(G.runGames, G.sets[0], G.splatT, false);
});
// eaten: any tap or key returns to the title — but only once the death animation has played out, so
// a panic tap during the gore burst can't skip it. A modal owns its own gesture (see input.js).
// NOT a page reload any more: that reset everything honestly but also tore down the AudioContext, so
@@ -187,6 +191,7 @@ addEventListener('keydown', e => {
const maybeRestart = () => {
if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return;
restartRun();
refreshSplash(); // the run just ended: both splash figures have moved
intro.hidden = false; go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
@@ -264,5 +269,5 @@ requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
step: dt => update(dt), render, press: keyPressed,
stats, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+4 -1
View File
@@ -198,7 +198,10 @@ function drawRunOff() {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 84; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 85;
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
+4 -1
View File
@@ -7,7 +7,10 @@ import { gore } from './zombies.js'; // runtime-only cycle (zombies imports stat
export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 };
export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2,
active: false, lastHit: 'P', bounces: 0, firstLand: null, collected: false, kills: 0, bomb: false, flame: false };
active: false, lastHit: 'P', bounces: 0, firstLand: null, collected: false, kills: 0, bomb: false, flame: false,
// which weapon launched the ball in flight — the stats screen files a kill under it long after
// the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free.
wid: 'ball' };
export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
export const crowd = { cheer: 0, dots: [] }; // dots are (re)built by render.buildCrowd
+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';
}
+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; }