Phase 3: procedural gore piles

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>
This commit is contained in:
wuwiandClaude Opus 4.8 committed 2026-07-11 22:06:04 +02:00
1 parent b6119c937b
commit ad4c8041b9
3 files changed
+59 -11

No files matched your search

+3 -3
View File
@@ -8,7 +8,7 @@ import { P, O, ball, score, G, setupPoint } from './state.js';
import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js'; import { update, keyPressed, solveShot, inServeBox, inCourt, forceZombies } from './gameplay.js';
import { fitViewport, render, proj } from './render.js'; import { fitViewport, render, proj } from './render.js';
import { voice } from './audio.js'; import { voice } from './audio.js';
import { zombies, spawnZombie } from './zombies.js'; import { zombies, spawnZombie, piles, gore, parts } from './zombies.js';
import { cv } from './dom.js'; import { cv } from './dom.js';
import './input.js'; import './input.js';
@@ -54,6 +54,6 @@ cv.addEventListener('pointerdown', e => {
requestAnimationFrame(frame); requestAnimationFrame(frame);
// exposed for in-browser sanity checks and simulated playtests // exposed for in-browser sanity checks and simulated playtests
window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, solveShot, inServeBox, inCourt, proj, setupPoint, window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, solveShot, inServeBox, inCourt, proj, setupPoint,
step: dt => update(dt), press: keyPressed, step: dt => update(dt), render, press: keyPressed,
get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
+41 -3
View File
@@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js';
import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { TUNE, COURT, PLAYER_H, BOY_H, UMP_H, BOUNCE_IDLE, SPRITE_H } from './config.js';
import { P, O, ball, score, crowd, G } from './state.js'; import { P, O, ball, score, crowd, G } from './state.js';
import { predictLanding } from './gameplay.js'; import { predictLanding } from './gameplay.js';
import { zombies, gore, parts } from './zombies.js'; import { zombies, gore, piles, parts } from './zombies.js';
// ---------------------------------------------------------------- palette ---- // ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them // tennis palette -> drained splatter palette; the transition lerps between them
@@ -130,6 +130,42 @@ function drawRing(x, z, color) {
ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 7); ctx.stroke();
} }
// gore piles: little 3D-ish mounds that accrete where kills cluster. Each pile's blobs are
// seeded ONCE from p.seed (rebuilt only when it grows), so the pile never shimmers frame to
// frame and drawing costs one proj() call — blob screen positions offset from that base.
function mulberry32(a) {
return () => { a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}
const PILE_COLS = ['#6e1414', '#8c1212', '#4e0e0e', '#7a2a18'];
function buildBlobs(pl) {
const rnd = mulberry32((pl.seed | 0) + 1), n = Math.round(6 + pl.size * 5);
pl.blobs = [];
for (let k = 0; k < n; k++) {
const ang = rnd() * 6.2832, rad = Math.sqrt(rnd()); // uniform over the disk
pl.blobs.push({ nx: Math.cos(ang) * rad, nz: Math.sin(ang) * rad,
lift: rnd(), pr: 1 + rnd() * 2, color: rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0] });
}
pl.blobs.sort((a, b) => (b.nz - 1.25 * b.lift) - (a.nz - 1.25 * a.lift)); // back/low first, peak on top
pl.builtSize = pl.size;
}
function drawGorePile(pl) {
if (!pl.blobs || pl.size > pl.builtSize) buildBlobs(pl); // rebuild only on growth, never on the mop-shrink
const base = proj(pl.x, 0, pl.z), s = base.s, sz = pl.size;
for (const b of pl.blobs) {
const sx = base.x + b.nx * sz * 0.28 * s;
const sy = base.y + b.nz * sz * 0.28 * s * 0.4 - b.lift * sz * 0.14 * s; // depth squished onto ground; lift raises it
const r = Math.max(1.2, b.pr * s * 0.035);
ctx.fillStyle = b.color;
ctx.beginPath(); ctx.ellipse(sx, sy, r, Math.max(1, r * 0.72), 0, 0, 7); ctx.fill();
}
}
function drawPiles(near) { // split at the net like zombies so height occludes correctly
for (const pl of piles.filter(p => near ? p.z > COURT.netZ : p.z <= COURT.netZ).sort((a, b) => a.z - b.z))
drawGorePile(pl);
}
function drawChar(ch) { function drawChar(ch) {
const receiverWaiting = G.phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle'; const receiverWaiting = G.phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle';
const sway = receiverWaiting ? Math.sin(G.idleT * 3.2) : 0; const sway = receiverWaiting ? Math.sin(G.idleT * 3.2) : 0;
@@ -209,12 +245,13 @@ export function render() {
line(0, COURT.svcFar, 0, COURT.svcNear, 1); line(0, COURT.svcFar, 0, COURT.svcNear, 1);
line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks
// gore decals: what remains of the visiting team // gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
for (const d of gore) { for (const d of gore) {
const p = proj(d.x, 0, d.z); const p = proj(d.x, 0, d.z);
ctx.fillStyle = `rgba(140,18,18,${d.a})`; ctx.fillStyle = `rgba(140,18,18,${d.a})`;
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill(); ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * d.r * 0.4, p.s * d.r * 0.16, 0, 0, 7); ctx.fill();
} }
drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net
// landing rings: white = incoming ball, yellow/red = your shot (red lands out) // landing rings: white = incoming ball, yellow/red = your shot (red lands out)
if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') { if (G.phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') {
@@ -240,6 +277,7 @@ export function render() {
ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5; ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke();
ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y); ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y);
drawPiles(true); // near-side piles: after the net (they're closer), before the near actors
if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy); if (G.boy && G.boy.z > COURT.netZ) drawBoy(G.boy);
zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie); zombies.filter(zb => zb.z > COURT.netZ).sort((a, b) => a.z - b.z).forEach(drawZombie);
@@ -255,7 +293,7 @@ export function render() {
// splatter particles // splatter particles
for (const pt of parts) { for (const pt of parts) {
const pp = proj(pt.x, pt.y, pt.z); const pp = proj(pt.x, pt.y, pt.z);
ctx.fillStyle = 'rgba(170,22,22,0.9)'; ctx.fillStyle = pt.dark ? 'rgba(96,14,14,0.9)' : 'rgba(170,22,22,0.9)';
ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2); ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2);
} }
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
+15 -5
View File
@@ -9,8 +9,9 @@ import { splat, moan, pock, voice } from './audio.js';
import { splatPoint, cheer } from './gameplay.js'; import { splatPoint, cheer } from './gameplay.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t} 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; let spawnT = 1.5, moanT = 3;
@@ -25,11 +26,15 @@ function splatter(i) {
ball.kills++; // this shot's running streak 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 }); gore.push({ x: zb.x, z: zb.z, r: 0.45 + Math.random() * 0.35, a: 0.85 });
if (gore.length > 40) gore.shift(); if (gore.length > 40) gore.shift();
for (let k = 0; k < 14; k++) parts.push({ 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, 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, vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.5,
t: 0.4 + Math.random() * 0.4, t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark,
}); }); }
splat(); splat();
ball.vx *= TUNE.zPierce; ball.vz *= TUNE.zPierce; // keep enough pace to plow through the next 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 sendBoy(zb.x, zb.z); // a boy sprints out to mop
@@ -95,4 +100,9 @@ export function updateZombies(dt) {
if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * 1.6; 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); 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);
}
} }