// 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: 2.2, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent gore.push({ x, z, r: 2.0, a: 0.9 }); if (gore.length > 40) gore.shift(); // a ring of splatter thrown outward, so the blast paints the lawn instead of leaving one puddle for (let k = 0; k < 6; k++) { const a = Math.random() * 6.2832, r = TUNE.bombRadius * (0.35 + Math.random() * 0.6); gore.push({ x: x + Math.cos(a) * r, z: z + Math.sin(a) * r, r: 0.7 + Math.random() * 0.7, a: 0.85 }); 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: 4, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); } // the eruption itself: a fat, slow, high-arcing column of gore rather than a camera flash for (let k = 0; k < 120; k++) parts.push({ x: x + (Math.random() * 2 - 1) * 0.4, y: 0.5, z: z + (Math.random() * 2 - 1) * 0.4, vx: (Math.random() * 2 - 1) * 11, vy: 2 + Math.random() * 9, vz: (Math.random() * 2 - 1) * 11, t: 0.7 + Math.random() * 1.1, dark: k % 3 === 0, }); G.hitstop = 0.18; boom(); G.shake = { t: 0.45, mag: 9 }; // camera kick // a warm, brief bloom instead of a white-out — the blinding frame was doing the work the gore // should be doing, and on a phone it just hurt (see render.js for the colour) G.boomFlash = 0.06; 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); } }