diff --git a/main.js b/main.js index ab2eb08..cc9983e 100644 --- a/main.js +++ b/main.js @@ -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 { fitViewport, render, proj } from './render.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 './input.js'; @@ -54,6 +54,6 @@ cv.addEventListener('pointerdown', e => { requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests -window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, solveShot, inServeBox, inCourt, proj, setupPoint, - step: dt => update(dt), press: keyPressed, +window.T = { TUNE, ball, P, O, score, G, zombies, spawnZombie, piles, gore, parts, solveShot, inServeBox, inCourt, proj, setupPoint, + step: dt => update(dt), render, press: keyPressed, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/render.js b/render.js index a509adf..dceb271 100644 --- a/render.js +++ b/render.js @@ -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 diff --git a/zombies.js b/zombies.js index 6e89544..6d30078 100644 --- a/zombies.js +++ b/zombies.js @@ -9,8 +9,9 @@ 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} -export const parts = []; // splatter particles {x, y, z, vx, vy, vz, t} +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; @@ -25,11 +26,15 @@ function splatter(i) { 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(); - 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, 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(); 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 @@ -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 (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); + } }