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
@@ -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 { P, O, ball, score, crowd, G } from './state.js';
|
||||
import { predictLanding } from './gameplay.js';
|
||||
import { zombies, gore, parts } from './zombies.js';
|
||||
import { zombies, gore, piles, parts } from './zombies.js';
|
||||
|
||||
// ---------------------------------------------------------------- palette ----
|
||||
// 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();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const receiverWaiting = G.phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle';
|
||||
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, 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) {
|
||||
const p = proj(d.x, 0, d.z);
|
||||
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();
|
||||
}
|
||||
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)
|
||||
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.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);
|
||||
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);
|
||||
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
|
||||
for (const pt of parts) {
|
||||
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);
|
||||
}
|
||||
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
|
||||
|
||||
Reference in new issue
Block a user