Files
splatter-tennis/zombies.js
T
wuwiandClaude Opus 5 a09a5d7559 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>
2026-07-27 05:28:04 +03:00

407 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The splatter phase: shamblers, gore decals, particles. The ball is the weapon;
// tennis scoring keeps running via gameplay.splatPoint. Zombies advance toward
// your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, COURT } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude } from './gameplay.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
export const piles = []; // 3D-ish gore mounds {x, z, size 1..4, seed} — grow where kills cluster
export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t, dark}
export const pellets = []; // machine-gun rounds during a horde {x, y, z, vx, vz, t}
export const extras = []; // triple-serve side balls {x, y, z, vx, vy, vz, pow, t} — ballistic clones
let spawnT = 1.5, moanT = 3;
export function spawnZombie(x, z, variant) {
const ramp = 1 + Math.min(1.5, G.splatT / 70); // they get faster the longer you survive
const zb = { x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9,
homeOff: (Math.random() * 2 - 1) * 0.8, variant }; // personal lane offset so they don't stack into a column
if (variant === 'f') { zb.v = Math.min(TUNE.runnerVMax, zb.v * TUNE.runnerSpeed); shriek(); } // shriek = the fairness contract; capped so late runners stay reactable
if (variant === 'a' || variant === 'b') zb.hp = TUNE.shamblerHp; // classic shamblers take a wound before they drop — only the runner is one-shot glass
if (variant === 'g') { zb.v *= 0.55; zb.hp = TUNE.bruteHp; } // slow, but soaks hits
if (variant === 's') zb.shieldT = 0; // chair up; > 0 = seconds of stagger left
zombies.push(zb);
}
// which shambler steps out next: the roster grows one type per set (runner: set 1 game 3+,
// brute: set 2, shield: set 3). Fixed random slots so the classics stay the crowd.
function pickVariant() {
const tier = G.sets[0] + G.sets[1], r = Math.random();
if (tier >= 2 && r < 0.15) return 's';
if (tier >= 1 && r >= 0.15 && r < 0.30) return 'g';
if ((tier >= 1 || G.runGames >= 2) && r >= 0.30 && r < 0.42
&& !zombies.some(z => z.variant === 'f')) return 'f'; // at most one runner alive
return Math.random() < 0.5 ? 'a' : 'b';
}
// core kill: remove the zombie, spawn its gore/pile/particles, score. No ball coupling —
// shared by ball hits, machine-gun pellets, melee swipes, and extra (multi-serve) balls.
// how absurdly a variant dies: classics splash, the advanced roster DETONATES
const GORE_MUL = { a: 1, b: 1, f: 1.8, s: 2.5, g: 4, r: 3 };
function killZombie(i, quiet) {
const zb = zombies[i];
const mul = GORE_MUL[zb.variant] || 1;
zombies.splice(i, 1);
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
for (let k = 0; k < stains; k++) {
gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0),
r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul), a: 0.85 });
if (gore.length > 40) gore.shift();
}
const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
if (pl) pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5));
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
if (piles.length > 30) piles.shift(); }
const n = Math.round(30 * mul); // base bumped 24 -> 30, then the multiplier goes to work
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
x: zb.x, y: 0.9, z: zb.z,
vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.5,
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark,
}); }
splat();
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
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) {
for (let k = 0; k < 5; k++) parts.push({
x: zb.x + (Math.random() * 2 - 1) * 0.15, y: 1.4, z: zb.z,
vx: (Math.random() * 2 - 1) * 1.6, vy: 0.6 + Math.random() * 1.4, vz: (Math.random() * 2 - 1) * 1.6,
t: 0.35 + Math.random() * 0.3, dark: Math.random() < 0.6 });
}
// swishing the racket cleaves every shambler in reach — it's the panic button for when they're
// already on top of you, so it stays a full sweep. TUNE.meleeCool is the brake that stops it
// from being the default attack. (the racket-zombie boss shrugs melee off entirely)
export function meleeSwipe(x, z, range) {
let hit = 0;
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
if (Math.hypot(zb.x - x, zb.z - z) < range) { killZombie(i, !!G.horde); hit++; }
}
if (hit) swish(0.35);
return hit;
}
function splatter(i, fanfare = true) { // the MAIN ball hit a zombie
killZombie(i, !!G.horde);
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
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); }
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); }
}
// ---- horde wave: dense spawns + an auto-firing machine-gun racket (pellets, not the match ball)
function spawnHordeZombie() { // all from the far baseline, across the court — never off-screen at the sides
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
}
function firePellet() {
const a = (Math.random() * 2 - 1) * 4 * Math.PI / 180, s = 26; // ±4° spread, straight up-court (-z)
pellets.push({ x: P.x, y: 0.9, z: P.z, vx: Math.sin(a) * s, vz: -Math.cos(a) * s, t: 1.2 });
pock(0.25, 1400); // rapid fire tick; if it clips on device, gate to every 2nd pellet
}
export function spawnRacketZombie() { // one takes up residence on the far court — an opponent, of sorts
zombies.push({ x: (Math.random() * 2 - 1) * 2.5, z: 3.5, v: 0, walkT: Math.random() * 9,
variant: 'r', side: 'O', facing: 1, pose: 'idle', poseT: 0, swingT: -1, swingCool: 0, runT: 0, hp: TUNE.racketHp });
}
function endHorde() {
G.horde = null; pellets.length = 0;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // survive the wave → a racket zombie stays
setupPoint(); // serve loop resumes; any zombies still standing are yours to mop up
voice('quiet_dark', 0.5, 0.85);
}
function tickHorde(dt) {
const h = G.horde;
h.t -= dt;
if ((h.spawnT -= dt) <= 0) { spawnHordeZombie(); h.spawnT = 1 / TUNE.hordeRate; }
if ((h.mgT -= dt) <= 0) { firePellet(); h.mgT = 1 / TUNE.mgRate; }
if (h.t <= 0) endHorde();
}
// 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) {
for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices (and can sweep) the array
if (zombies[i] && Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i);
gore.push({ x, z, r: 1.6, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent
gore.push({ x, z, r: 1.4, a: 0.9 }); if (gore.length > 40) gore.shift();
const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6);
if (pl) pl.size = Math.min(4, pl.size + 3);
else { piles.push({ x, z, size: 3, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); }
for (let k = 0; k < 40; k++) parts.push({
x, y: 0.6, z, vx: (Math.random() * 2 - 1) * 6, vy: 1 + Math.random() * 5, vz: (Math.random() * 2 - 1) * 6,
t: 0.5 + Math.random() * 0.7, dark: k % 2 === 0,
});
G.hitstop = 0.18; boom();
G.shake = { t: 0.35, mag: 6 }; // camera kick
G.boomFlash = 0.08; // one white frame
if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure
}
// wounded zombies bleed from every open wound — a fine, sparse trickle (a small spurt from the
// neck stump on the headless boss). Anchors are approximate body offsets [x, y, rise]; most
// frames a wound drips nothing, and the tight spread keeps each thread thin. Bounded low.
function bleed(zb) {
if (parts.length > 90) return;
let wounds;
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
else return;
for (const w of wounds) {
if (Math.random() > 0.4) continue; // sparse: most frames this wound emits nothing
parts.push({
x: zb.x + w[0] + (Math.random() * 2 - 1) * 0.03, y: w[1] + (Math.random() * 2 - 1) * 0.03, z: zb.z + (Math.random() * 2 - 1) * 0.03,
vx: (Math.random() * 2 - 1) * 0.3, vy: w[2] * (0.6 + Math.random() * 0.5), vz: (Math.random() * 2 - 1) * 0.28,
t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 });
}
}
export function updateZombies(dt) {
moanT -= dt;
if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; }
if (G.mode === 'splatter') { // transition spawns are scripted, not timed
G.splatT += dt;
if (G.horde) tickHorde(dt); // horde: dense spawns + machine gun, ignores the cap
else if (G.mgT > 0 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules
G.mgT -= dt;
if ((G.mgCool -= dt) <= 0) { firePellet(); G.mgCool = 1 / TUNE.mgRate; }
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
}
if (!G.horde && !G.changeover && !G.interlude && !G.clearing) { // breathers, duels AND the mop-up after a won game get a quiet court (no new spawns)
spawnT -= dt;
// the spawn director: the match's age (games finished) sets both crowd size and tempo
const cap = Math.min(TUNE.zMaxCap, TUNE.zMax + G.runGames);
if (spawnT <= 0 && zombies.length < cap) {
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), pickVariant());
spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep);
}
}
}
// ponytail: crude swept collision — grow the contact window by half the distance the ball
// covers this frame. A full-draw smash moves ~0.7 m per frame (1.6 m on a 25 fps device, where
// main.js clamps dt), which is wider than a shambler, so a discrete point test tunnels straight
// through bodies. Upgrade to a real segment-vs-circle test if the fudge ever reads as a phantom
// hit; at these speeds and radii it doesn't.
const ballSweep = ball.active ? Math.hypot(ball.vx, ball.vz) * dt * 0.5 : 0;
for (let i = zombies.length - 1; i >= 0; i--) {
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;
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
if (zb.variant === 'r') { /* the racket boss holds station — its AI owns its x */ }
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
const tx = P.x + zb.homeOff;
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
+ Math.sin(zb.walkT * 2.3) * 0.25 * dt; // shamble wobble — the approach reads organic, not tracked
} else
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // far half: aimless shamble
if (G.mode === 'splatter' && 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
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 });
for (let k = 0; k < 30; k++) parts.push({
x: P.x, y: 1.1, z: P.z,
vx: (Math.random() * 2 - 1) * 4.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 4.5,
t: 0.5 + Math.random() * 0.5,
});
splat(1);
voice('game_set_massacre', 0.8); aww(1); // the run is over — the crowd groans
// 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 };
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
}
if (ball.active && ball.lastHit === 'P' && ball.y < 1.9) {
const d = Math.hypot(ball.x - zb.x, ball.z - zb.z);
if (d < (zb.variant === 'g' ? 0.75 : 0.55) + ballSweep) { // the brute is a bigger target
if (ball.bomb) { // detonate on first contact, whatever the pace
explode(ball.x, ball.z); ball.bomb = false;
spentBall(); break; // zombies mutated — stop this frame's loop
}
if (ball.flame) { // burns through anything it touches — no quality gate, no chair, no pace tax
if (zb.variant === 'r') { // the boss only takes a wound
zb.hp--; splat(0.5);
if (zb.hp <= 0) { splatter(i); } continue;
}
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)
if ((ball.pow || 0) >= TUNE.zSplatQ) {
zb.hp--; splat(0.5); pock(0.6, 300);
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit
if (zb.hp <= 0) { splatter(i); continue; } // killing blow: full gore + point
}
continue; // soft balls pass through, for the AI to return
}
if (zb.variant === 'g') { // brute: soaks splat-quality hits, never staggers back
if ((ball.pow || 0) >= TUNE.zSplatQ) {
zb.hp--; splat(0.5); pock(0.6, 220);
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X';
if (zb.hp <= 0) { splatter(i); continue; } // timber
} else { pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; } // soft ball bounces off
continue;
}
if (zb.variant === 's' && ball.vz < 0 && !(zb.shieldT > 0)) { // chair up, ball at its face: BLOCKED
zb.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens
zb.z -= TUNE.zPushback * 0.5;
pock(0.9, 1800); // wooden clank
ball.vx *= 0.4; ball.vz *= -0.2; ball.lastHit = 'X'; // ball caroms off the chair, spent
continue;
}
if (zb.variant === 'f') { splatter(i); continue; } // the sprinter is GLASS — any touch, any quality, it bursts
if (zb.variant === 'a' || zb.variant === 'b') { // classic shamblers soak a splat-quality wound before they drop
if ((ball.pow || 0) >= TUNE.zSplatQ) {
if (--zb.hp <= 0) { splatter(i); continue; } // the second splat is the killing blow
woundBurst(zb); splat(0.5); pock(0.6, 280); // first splat: a bloody wound, not a death
zb.z -= TUNE.zPushback * 0.5; ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; continue; // ball spent on the wound
}
zb.z -= TUNE.zPushback; pock(0.6, 300); ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; continue; // soft ball just staggers
}
// chair-down shield zombies (and anything hit from behind) obey the normal rules:
if ((ball.pow || 0) >= TUNE.zSplatQ) { splatter(i); continue; }
zb.z -= TUNE.zPushback; // weak sitters only stagger them
pock(0.6, 300);
ball.vx *= 0.6; ball.vz *= 0.6; ball.lastHit = 'X'; // spent — one stagger per ball
}
}
if (zb.z >= COURT.L + 0.7) { zombies.splice(i, 1); if (G.mode !== 'eaten' && !G.horde && !G.clearing) splatPoint('O'); } // it reached your baseline (during a mop-up, a straggler that reaches the line just despawns — no point for them)
}
for (let i = pellets.length - 1; i >= 0; i--) { // machine-gun rounds: flat, fast, one-shot kills
const pe = pellets[i];
pe.t -= dt; pe.x += pe.vx * dt; pe.z += pe.vz * dt;
let hit = false;
for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j];
if (!zb || zb.variant === 'r') continue;
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) {
if (zb.hp > 1) { zb.hp--; pock(0.5, 260); } // brutes AND shamblers soak pellets — wound, don't kill
else killZombie(j, true);
hit = true; break;
}
}
if (hit || pe.t <= 0) pellets.splice(i, 1);
}
for (let i = extras.length - 1; i >= 0; i--) { // triple-serve side balls: full ballistic clones
const e = extras[i];
e.t -= dt; e.vy -= TUNE.gravity * dt;
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; }
let dead = e.t <= 0 || Math.abs(e.x) > 7.5 || e.z < -2 || e.z > 25.5;
if (!dead && e.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) {
const zb = zombies[j];
if (!zb || zb.variant === 'r') continue; // side balls pass the boss; !zb: kills can sweep mid-loop
if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)
+ Math.hypot(e.vx, e.vz) * dt * 0.5) { // same sweep: clones inherit the smash pace
if (zb.variant === 's' && e.vz < 0 && !(zb.shieldT > 0)) { zb.shieldT = TUNE.shieldDown; pock(0.9, 1800); } // chair blocks these too
else if (e.pow >= TUNE.zSplatQ || zb.variant === 'f') {
if (zb.hp > 1) { zb.hp--; splat(0.4); } // wounds brutes AND shamblers
else killZombie(j, !!G.horde);
} else { zb.z -= TUNE.zPushback; pock(0.5, 300); }
dead = true; break; // a side ball is spent on its first touch
}
}
if (dead) extras.splice(i, 1);
}
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
p.t -= dt; p.vy -= 14 * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
if (p.y < 0) { p.y = 0; p.vy = 0; p.vx *= 0.5; p.vz *= 0.5; }
if (p.t <= 0) parts.splice(i, 1);
}
const boy = G.boy; // the boy mops any gore he passes over
for (let i = gore.length - 1; i >= 0; i--) {
const d = gore[i];
if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * (d.scorch ? 0.16 : 1.6); // craters scar the lawn 10× longer
if (d.a <= 0) gore.splice(i, 1);
}
for (let i = piles.length - 1; i >= 0; i--) { // piles shave far slower than stains — net growth wins
const pl = piles[i];
if (boy && Math.abs(boy.x - pl.x) < 0.7 && Math.abs(boy.z - pl.z) < 1.2) pl.size -= dt * 0.25;
if (pl.size <= 0.3) piles.splice(i, 1);
}
}