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:
1 parent
b6119c937b
commit
ad4c8041b9
3 files changed
+59
-11
No files matched your search
@@ -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; } };
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user