Files
splatter-tennis/zombies.js
T
wuwiandClaude Opus 4.8 9100bd5a23 Splatter feel pass: melee, homing, multi-ball, clearer MG mode
From Oliver's feedback round:

- Racket melee: any swish (tap) in splatter cleaves shamblers within
  TUNE.meleeRange (1.7m) — a close-range survival tool. Works during a
  horde too (tap = swish only, still no serve). The racket-zombie boss
  (Phase 6, not built) is immune by design.
- Zombies home in on you: on YOUR half (z > netZ) they now drift toward
  P.x instead of walking a straight lane; far half stays an aimless
  shamble.
- Multi-ball / rapid fire: splatter is now a shooter — every tap/flick
  fires immediately (dropped the toss ritual there), and serving while a
  ball still flies stashes the old one into a new extraBalls array so it
  keeps arcing and killing (cap 6). Refactored the kill into a shared
  killZombie() with no ball coupling, used by ball hits, pellets, melee,
  and extra balls.
- Machine-gun mode clarity: confirmed free-walk (drag/arrows move you,
  auto-assist off) and tap-doesn't-serve during a horde; added a
  code-drawn "rambo" racket held forward with a muzzle flash over the
  player, and a splatter-specific serve prompt.

REVIEW on your phone — two opinionated calls, both easy to revert:
1. Dropped the toss in splatter (tap = instant quick-serve, pow 0.62).
   Rapid fire, but no apex-timing quality/pierce. Want the toss back as
   an option?
2. MG posture is a code-drawn forward racket, not a real body sprite —
   good enough, or worth a proper p_mg sprite later?
Also: each rapid serve still auto-consumes a stocked bomb, so bombs
deplete fast now — flag if that feels wrong.

Verified via window.T: melee kills a 1.0m zombie and spares a 4.5m one;
a near-half zombie drifts x 3→1.9 toward you; firing twice leaves 1
stashed ball + a fresh serve (barrage screenshot); extra balls kill and
pierce; free-walk during a horde (x 0→2.7); horde-tap melees without
serving; tennis toss-serve and Phase-4 bomb consume still work; no
console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:54:31 +02:00

217 lines
11 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 } 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() {
if (Math.random() < 0.5) spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), Math.random() < 0.5 ? 'a' : 'b'); // far baseline
else spawnZombie(Math.random() < 0.5 ? -6 : 6, Math.random() * 8, Math.random() < 0.5 ? 'a' : 'b'); // in from the sides
}
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);
}
}