// 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 } from './audio.js'; import { splatPoint, cheer, spentBall } 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} 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 zombies.push({ x, z, v: TUNE.zSpeed * ramp * (0.8 + Math.random() * 0.4), walkT: Math.random() * 9, variant }); } // 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. function killZombie(i, quiet) { const zb = zombies[i]; zombies.splice(i, 1); gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, 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 + 1); else { piles.push({ x: zb.x, z: zb.z, size: 1, seed: Math.random() * 1e4, blobs: null, builtSize: 0 }); if (piles.length > 30) piles.shift(); } for (let k = 0; k < 24; k++) { const dark = k >= 16; 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(); sendBoy(zb.x, zb.z); // a boy sprints out to mop if (!G.horde) splatPoint('P', quiet); // a horde is survival — it doesn't score, so it can't chain into itself } // swishing the racket cleaves any shambler in reach (the racket-zombie boss shrugs melee off) export function meleeSwipe(x, z, range) { let hit = 0; for (let i = zombies.length - 1; i >= 0; i--) { if (zombies[i].variant === 'r') continue; if (Math.hypot(zombies[i].x - x, zombies[i].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); } // bomb-ball reward: every bombEvery kills a boy delivers one (up to bombMax in stock) if (++G.killMeter >= TUNE.bombEvery) { G.killMeter = 0; if (G.bombs < TUNE.bombMax) { G.bombs++; sendBoy(); voice('new_balls', 0.2); G.flash = { text: 'BOMB BALL', t: 0.9 }; } } } // ---- 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 } function spawnRacketZombie() { // one takes up residence on the far court each horde you survive 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; spawnRacketZombie(); // survive the wave → a racket zombie stays to rally you 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 the array if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i); 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.1; boom(); } 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.changeover) { // changeovers are a real breather — no fresh 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(), Math.random() < 0.5 ? 'a' : 'b'); spawnT = Math.max(TUNE.zSpawnMin, TUNE.zSpawnEvery - G.runGames * TUNE.zSpawnStep); } } } for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i]; zb.walkT += dt; zb.z += zb.v * dt; 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 home in on you zb.x += Math.sign(P.x - zb.x) * Math.min(Math.abs(P.x - zb.x), zb.v * 0.7 * dt); 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.7) { // it got 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); G.mode = 'eaten'; ball.active = false; G.flash = null; // zombies keep shambling for ambience G.horde = null; pellets.length = 0; // the swarm 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 }; if (G.runGames > b.games) { 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 < 0.55) { 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 (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 ((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) splatPoint('O'); } // it reached your baseline } 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--) if (zombies[j].variant !== 'r' && Math.hypot(zombies[j].x - pe.x, zombies[j].z - pe.z) < 0.55) { killZombie(j, true); hit = true; break; } if (hit || pe.t <= 0) pellets.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 * 1.6; 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); } }