Polish round: modal system, softened palette, pixel font, and the 14 plan items

The three foundations first, since everything else leans on them:

- G.modal is the only thing that stops the world. update() early-returns on it
  so no zombie steps, no ball flight, no spawn timers run while a popup is on
  screen — you can read it without being eaten. Dismissal swallows the gesture;
  the pointerdown dismisser is registered only on pointer:fine, because on touch
  pointerdown beats touchstart and a shared handler cleared the modal and then
  served with the same thumb.
- UI in config.js is the accent palette. The arcade primaries were shouting over
  the artwork; the gore reds deliberately stay loud.
- Every canvas font string now goes through F(). Bare `monospace` resolved to
  Courier — a slab serif — which was the "generic serif" bug, not taste.

Then: weapons are only ever WON. deliverTin() has exactly one caller now
(resolveInterlude('P')); the old game schedule calls the DUEL instead, fired off
proceedAfterClear() where the court is guaranteed empty. Four popups (splatter
tutorial with a generated illustration, first duel, weapon received, horde),
wCap 9 with the tin amounts tunable, a 5-hp brute that comes apart over four
wound skins, fire that ignites the neighbours and chains outward, a three-stage
death where you become the biggest gore mound on the court, and a 7s changeover
with a ball boy who finally has a mop.

New art: assets/logo.png, x_howto, z_gw2-4, x_boymop1-4, plus assets/_key.py for
standalone art (chroma-key + crop, no frame splitting, no forced sprite height).

Benchmarks re-run and holding: CPU returns 23/24 serves, 30 human-tapped points
= 50 hits / 0 whiffs, 26 simulated games with no exceptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-27 05:28:04 +03:00
1 parent 5faaa99467
commit a09a5d7559
23 files changed
+392 -103

No files matched your search

+58 -5
View File
@@ -67,10 +67,52 @@ function killZombie(i, quiet) {
}); }
splat();
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
sendBoy(zb.x, zb.z); // a boy sprints out to mop
sendBoy(zb.x, zb.z, true); // a boy comes out with the mop
if (!G.horde && !G.clearing) splatPoint('P', quiet); // horde = survival, clearing = mopping up an already-won game — neither scores
}
// ---- fire. The flame ball doesn't just kill what it touches, it starts an epidemic: the kill
// ignites its neighbours, and every burning body keeps igniting whatever it shambles past.
// Read-only over zombies[] (it only sets burnT), so it is safe to call from inside a kill loop.
export function ignite(x, z, r) {
for (const zb of zombies) {
if (!zb || zb.variant === 'r' || zb.burnT > 0) continue; // the duel opponent doesn't burn
if (Math.hypot(zb.x - x, zb.z - z) < r) { zb.burnT = TUNE.burnTime; if (zb.hp === undefined) zb.hp = 1; }
}
}
// one burning body's frame: damage, spread, embers. Returns true if the fire killed it (the
// caller must then stop touching zb — killZombie splices, and splatPoint can cascade further).
function burnTick(zb, i, dt) {
zb.burnT -= dt;
ignite(zb.x, zb.z, TUNE.burnSpread);
zb.burnD = (zb.burnD || 0) + TUNE.burnDps * dt;
while (zb.burnD >= 1) { zb.burnD -= 1; zb.hp--; }
if (parts.length < 110) for (let k = 0; k < 2; k++) parts.push({ // embers off the body
x: zb.x + (Math.random() * 2 - 1) * 0.3, y: 0.5 + Math.random() * 1.4, z: zb.z,
vx: (Math.random() * 2 - 1) * 0.5, vy: 1.2 + Math.random(), vz: (Math.random() * 2 - 1) * 0.5,
t: 0.25 + Math.random() * 0.2, fire: true });
if (zb.hp > 0) return false;
killZombie(i, !!G.horde);
return true;
}
// phase B of the death animation (EATEN_BURST, config.js): the player IS the gore now. Reuses killZombie's machinery —
// the biggest mound on the court, and it stays visible under the summary scrim. That's the point.
export function playerGore() {
const at = G.eatenAt || { x: P.x, z: P.z };
gore.push({ x: at.x, z: at.z, r: 1.7, a: 1 }); if (gore.length > 40) gore.shift();
piles.push({ x: at.x, z: at.z, size: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
if (piles.length > 30) piles.shift();
for (let k = 0; k < 90; k++) parts.push({
x: at.x, y: 1.0, z: at.z,
vx: (Math.random() * 2 - 1) * 5.5, vy: 1 + Math.random() * 6, vz: (Math.random() * 2 - 1) * 5.5,
t: 0.6 + Math.random() * 0.9, dark: k % 3 === 0 });
splat(1); boom();
G.shake = { t: 0.35, mag: 7 };
G.boomFlash = 0.08;
}
// a small dark spatter the instant a multi-hit shambler is wounded — subtle impact pop, NOT the
// kill spray (the ongoing thin trickle is bleed()'s job). Kept low per the settled blood feel.
function woundBurst(zb) {
@@ -158,7 +200,14 @@ export function explode(x, z) {
function bleed(zb) {
if (parts.length > 90) return;
let wounds;
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) wounds = [[-0.3, 1.95, 1.1]]; // brute: torn-off arm
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) {
// the brute opens up stage by stage: one arm, then both, then the head. Still sparse —
// more ANCHORS, not a heavier flow (the settled feel note: blood stays thin).
wounds = [[-0.3, 1.95, 1.1]];
if (zb.hp <= TUNE.bruteHp - 2) wounds.push([0.42, 1.9, 1.0]);
if (zb.hp <= TUNE.bruteHp - 3) wounds.push([0.05, 2.35, 2.0]);
if (zb.hp <= 1) wounds.push([0.0, 1.2, 0.7]);
}
else if (zb.variant === 'r' && zb.hp <= 1) wounds = [[0.05, 1.5, 2.4], [0.4, 1.15, 0.9]]; // headless: neck spurt + torn arm
else if (zb.variant === 'r' && zb.hp === 2) wounds = [[0.4, 1.2, 1.0]]; // racket: torn-off arm
else if ((zb.variant === 'a' || zb.variant === 'b') && zb.hp < TUNE.shamblerHp) wounds = [[0.28, 1.35, 0.85]]; // shambler: gashed shoulder
@@ -206,7 +255,8 @@ export function updateZombies(dt) {
const zb = zombies[i];
if (!zb) continue; // a bomb detonation splices several out mid-loop and can gut the array under us
zb.walkT += dt;
zb.z += zb.v * dt;
if (zb.burnT > 0 && burnTick(zb, i, dt)) continue; // the fire finished it — the array moved under us
zb.z += zb.v * (zb.burnT > 0 ? TUNE.burnPanic : 1) * dt;
bleed(zb); // open wounds bleed every frame they're standing
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
@@ -226,7 +276,9 @@ export function updateZombies(dt) {
});
splat(1);
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans
G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
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; G.mgT = 0; // everything stops; the eaten screen takes over
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 };
@@ -247,8 +299,9 @@ export function updateZombies(dt) {
zb.hp--; splat(0.5);
if (zb.hp <= 0) { splatter(i); } continue;
}
const vx0 = ball.vx, vz0 = ball.vz;
const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z;
splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
continue;
}
if (zb.variant === 'r') { // mini-boss: splat-quality wounds it; soft balls it rallies (racketAI)