The freeze first: G.mgT only counted down inside `G.phase === 'rally'`, so anything that moved the phase mid-burst — a scheduled duel calling setupPoint — stranded it above zero forever. mgT > 0 is one of keyPressed's early returns, so no tap could serve again, and the yellow shape over the player's head was drawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks in every phase, only the pellets are gated on rally) and semantically: starting a duel cancels any burst, because the duel is tennis. The mop boy now does an actual round: walk to the biggest nearby stain, stand on it and push the mop while the cycle animates in place, scrub it out, move to the next, then leave. Gore fades ~3.5x faster while he is stopped and working than when walking past, so the stain dies under him instead of fading as he drifts by. boyMaxJobs caps the round so a kill streak can't employ him forever. Applause: the clip is 4.16s and always plays in full, but every cheer() passed a shorter beat, so the stand sat down while it was still audible. Floored at the clip length. New, much bolder clap frame — the re-roll came back brighter and bluer, which would have flashed the whole stand, so it is colour-matched to frame one per channel on mean and sd before quantising. Net and umpire were both wrong since forever: netW was the singles-stick width (10.06) against a court drawn to the doubles lines, so the net stopped 0.46m short each side; the chair's near legs stood 0.45m inside the sideline. Both now sit exactly on the doubles line. 26-game soak clean, no stalls. Serve returns 134/150 = 89.3% +/-2.5% with the wider net (the bar is inside one sigma), 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
418 lines
24 KiB
JavaScript
418 lines
24 KiB
JavaScript
// 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) { // machine-gun tin: horde firepower, regular rules
|
||
// The burst clock ticks in EVERY phase. It used to tick only during 'rally', so anything that
|
||
// moved the phase mid-burst (a duel calling setupPoint) froze mgT above zero forever — and
|
||
// `G.mgT > 0` blocks serving, so the game locked up with the gun racket stuck on screen.
|
||
G.mgT -= dt;
|
||
if (G.phase === 'rally' && (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;
|
||
|
||
// 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);
|
||
|
||
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];
|
||
// He scrubs HARD only while stopped and working the mop; walking past barely touches it, so
|
||
// the stain visibly dies under him rather than fading as he drifts by.
|
||
if (boy && Math.abs(boy.x - d.x) < 0.8 && Math.abs(boy.z - d.z) < 1.3)
|
||
d.a -= dt * (d.scorch ? 0.16 : boy.mopT > 0 ? 0.7 : 0.2); // craters scar the lawn far 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);
|
||
}
|
||
}
|