Kills now grow 3D-ish gore mounds, not just flat stains. zombies.js gains
a piles array {x,z,size,seed}: a kill within 0.6m of a pile grows it
(size +1, cap 4), else starts a new one (cap 30, oldest shifts out).
Ball boys shave overlapping piles at size -= dt*0.25 — far slower than
stain mopping (dt*1.6) — so net growth wins over a long match. Splatter
particles bumped 14→24, ~1/3 dark and longer-lived (settling gore),
render reads pt.dark for colour.
render.js draws each pile as seeded pixel-art blobs (mulberry32 from
p.seed): N=6+size*5 blobs, offset within size*0.28m, lifted up to
size*0.14m so the pile has visible height, weighted gore colours + ~10%
bone flecks. Blobs are built once and rebuilt only when the pile GROWS
(builtSize guard) — never on the per-frame mop-shrink — and each pile
costs a single proj() call (blob screen positions offset from the base).
Piles draw split far/near of the net like zombies so height occludes
right. window.T exposes render/piles/gore/parts for verification.
Verified via window.T: clustered kills build tall dense mounds while
singles stay small (screenshot), avg render 0.61ms with 10 piles, 0 blob
rebuilds across 120 frames, mop shrinks a size-4 pile at exactly 0.25/s,
no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
5.0 KiB
JavaScript
109 lines
5.0 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 } from './state.js';
|
||
import { splat, moan, pock, voice } 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}
|
||
|
||
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 });
|
||
}
|
||
|
||
function splatter(i) {
|
||
const zb = zombies[i];
|
||
zombies.splice(i, 1);
|
||
ball.kills++; // this shot's running streak
|
||
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();
|
||
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next
|
||
sendBoy(zb.x, zb.z); // a boy sprints out to mop
|
||
splatPoint('P');
|
||
// 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); }
|
||
}
|
||
|
||
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;
|
||
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;
|
||
zb.x += Math.sin(zb.walkT * 1.7) * 0.15 * dt; // shamblers don't walk straight lines
|
||
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
|
||
}
|
||
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.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'); } // it reached your baseline
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|