Files
splatter-tennis/zombies.js
T
wuwiandClaude Fable 5 06bd24e6ea Phase 12: the weapon ladder — every tin opens something nastier
Deliveries cycle BOMB -> TRIPLE -> FIRE -> GUN (G.deliveries indexes the
ladder); one tin at a time, a fresh delivery replaces unspent stock. All
weapons stay one-thumb: the serve auto-consumes, zero new inputs.

- Triple serve (x3): one real serve fans two ballistic clones +-6deg (extras[]
  in zombies.js — NOT the reverted rapid-fire: each fan costs a proper serve).
  Side balls respect variant rules (wound brutes, clank off chairs, pass the boss)
- Flame ball (x3): kills on ANY contact regardless of pow, chair ignored,
  full pierce (splatter pace tax undone), ember trail, white-hot render
- Machine gun (8s): horde auto-fire as a pickup — no serving, drag steers,
  no assist, spawner keeps running, ammo bar drains on the HUD, then the
  serve loop resumes
- HUD pips per type (black/yellow/orange) at the bomb slot; flash names the
  tin ('NEW BALLS: FIRE')

Verified: ladder cycles across 5 deliveries; triple spawns 2 extras and the
fan kills flanked targets; pow-0.1 flame ball clears shambler+shield+brute in
one pass with TRIPLE KILL chaining; GUN tin arms at changeover, blocks taps,
streams pellets (kill in 5 frames), expires back to pretoss; tennis 11/12;
no weapon-flag leaks into tennis; no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 10:58:28 +02:00

288 lines
15 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, shriek } 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}
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, variant };
if (variant === 'f') { zb.v *= TUNE.runnerSpeed; shriek(); } // the shriek is the fairness contract
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.
function killZombie(i, quiet) {
const zb = zombies[i];
const big = zb.variant === 'g'; // a brute goes down like a building — twice the gore
zombies.splice(i, 1);
gore.push({ x: zb.x, z: zb.z, r: (big ? 0.8 : 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 + (big ? 2 : 1));
else { piles.push({ x: zb.x, z: zb.z, size: big ? 2 : 1, seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
if (piles.length > 30) piles.shift(); }
const n = big ? 48 : 24;
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();
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); }
}
// ---- 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.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
}
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 && G.phase === 'rally') { // machine-gun tin: horde firepower, regular rules
G.mgT -= dt;
if ((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) { // 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(), pickVariant());
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.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 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) < (zb.variant === 'g' ? 0.9 : 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; 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 };
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 < (zb.variant === 'g' ? 0.75 : 0.55)) { // 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;
splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane
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;
}
// 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) 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--) {
const zb = zombies[j];
if (zb.variant === 'r') continue;
if (Math.hypot(zb.x - pe.x, zb.z - pe.z) < (zb.variant === 'g' ? 0.75 : 0.55)) {
if (zb.variant === 'g' && --zb.hp > 0) pock(0.5, 260); // the brute soaks pellets too
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.variant === 'r') continue; // side balls pass the boss — only the match ball duels it
if (Math.hypot(zb.x - e.x, zb.z - e.z) < (zb.variant === 'g' ? 0.75 : 0.55)) {
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) {
if (zb.variant === 'g' && --zb.hp > 0) splat(0.4); // wounds the brute
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];
if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * (d.scorch ? 0.16 : 1.6); // craters scar the lawn 10× 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);
}
}