The x=±6 side spawns forced you to machine-gun targets off the edge of your view/pellet lane. Now every horde zombie enters from the far baseline across the court like the normal spawns, so the whole swarm comes down in front of you. Verified via window.T: a full 12s horde spawns ~29 zombies, all at the far baseline (z -1.5..-2.5, |x| ≤ 4.9), zero side spawns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
11 KiB
JavaScript
216 lines
11 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 } from './audio.js';
|
||
import { splatPoint, cheer } 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 extraBalls = []; // earlier serves still in flight while you fire the next {x,y,z,vx,vy,vz,g,pow,bomb,bounces}
|
||
|
||
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
|
||
splatPoint('P', quiet); // horde kills rate-limit the umpire so he doesn't machine-gun scores
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// serving again while a ball still flies: stash the current one so it keeps killing (a barrage)
|
||
export function stashExtraBall() {
|
||
if (!ball.active) return;
|
||
extraBalls.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz,
|
||
g: ball.g, pow: ball.pow, bomb: ball.bomb, bounces: ball.bounces });
|
||
if (extraBalls.length > 6) extraBalls.shift(); // cap the barrage
|
||
}
|
||
|
||
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 endHorde() {
|
||
G.horde = null; pellets.length = 0;
|
||
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 zMax
|
||
else {
|
||
spawnT -= dt;
|
||
if (spawnT <= 0 && zombies.length < TUNE.zMax) {
|
||
spawnZombie((Math.random() * 2 - 1) * 4.8, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b');
|
||
spawnT = TUNE.zSpawnEvery;
|
||
}
|
||
}
|
||
}
|
||
|
||
for (let i = zombies.length - 1; i >= 0; i--) {
|
||
const zb = zombies[i];
|
||
zb.walkT += dt;
|
||
zb.z += zb.v * dt;
|
||
if (G.mode === 'splatter' && zb.variant !== 'r' && 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 / racket boss: 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; extraBalls.length = 0; // the swarm stops; the eaten screen takes over
|
||
}
|
||
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.active = false; ball.bomb = false;
|
||
G.phase = 'over'; G.overT = TUNE.splatterOverT; break; // zombies mutated — stop this frame's loop
|
||
}
|
||
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') splatPoint('O', !!G.horde); } // 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 = extraBalls.length - 1; i >= 0; i--) { // earlier serves keep arcing and killing
|
||
const b = extraBalls[i];
|
||
b.vy -= (b.g || TUNE.gravity) * dt;
|
||
b.x += b.vx * dt; b.y += b.vy * dt; b.z += b.vz * dt;
|
||
let done = false;
|
||
if (b.y <= 0 && b.vy < 0) { b.y = 0; b.vy = -b.vy * TUNE.bounceY; b.vx *= TUNE.bounceXZ; b.vz *= TUNE.bounceXZ; b.bounces++;
|
||
if (b.bomb) { explode(b.x, b.z); done = true; } }
|
||
if (!done && b.y < 1.9) for (let j = zombies.length - 1; j >= 0; j--) {
|
||
if (zombies[j].variant === 'r' || Math.hypot(zombies[j].x - b.x, zombies[j].z - b.z) >= 0.55) continue;
|
||
if (b.bomb) { explode(b.x, b.z); done = true; }
|
||
else if ((b.pow || 0) >= TUNE.zSplatQ) { killZombie(j, !!G.horde); b.vx *= TUNE.zPierce; b.vz *= TUNE.zPierce; }
|
||
break;
|
||
}
|
||
if (done || b.bounces >= 2 || b.z < -2 || b.z > 25.5 || Math.abs(b.x) > 7.5) extraBalls.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);
|
||
}
|
||
}
|