Die the way people die in the films

The old death was a red tint and a shake for half a second, then a puff of gore.
That reads as the sprite having a moment, not as being eaten.

Now it takes four and a half seconds and it is a crowd doing it. You are dragged
to your knees fighting it (new frames, p_die1/p_die2, alternating), blood coming
out of you the whole time — a constant spray and a stain that keeps widening,
with the wet work on a loop underneath. Every body on court drops what it was
doing and converges: each claims a spot in a ring around you, kept from wherever
it was standing, so they crowd in from all sides instead of stacking on one
pixel. Then you are pressed flat into the ground while the ring closes over you —
you are drawn INSIDE the crowd's z order, so the ones between you and the camera
hide you exactly as they arrive — and what is left is the mound, with them still
feeding over it when the summary finally fades in.

Also: the wounded shamblers were stepping their two frames at half the cadence of
the healthy four, which reads as frozen next to the crowd they walk in. Same
clock now. (They were animating — verified frame by frame — just too slowly.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-31 20:36:25 +02:00
1 parent b7899f122c
commit 4d6b47dadd
8 files changed
+88 -24

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+6 -1
View File
@@ -253,7 +253,12 @@ export const TUNE = {
// stand sitting back down while the applause was still audible. // stand sitting back down while the applause was still audible.
export const APPLAUSE_T = 4.2; export const APPLAUSE_T = 4.2;
export const EATEN_BURST = 0.5, EATEN_SUMMARY = 1.6, EATEN_RESTART = 2.2; // A: they drag you down and you fight it on your knees · B (EATEN_BURST): the crowd closes over you
// and what is left is the mound · C (EATEN_SUMMARY): the summary fades in over them still feeding.
// It is deliberately long. Oliver: "the movies surround and bite them, blood spills, and then the
// person basically drowns in the zombies" — that cannot happen in half a second.
export const EATEN_GRAB = 0.9, EATEN_BURST = 2.3, EATEN_SUMMARY = 3.6, EATEN_RESTART = 4.6;
export const EATEN_FEED_R = 1.15; // m — how tight the feeding ring packs around the body
// UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately // UI accent colours, one place so the whole HUD can be re-tuned at once. Deliberately
// desaturated from the original arcade primaries: they were shouting over the artwork. // desaturated from the original arcade primaries: they were shouting over the artwork.
+2 -1
View File
@@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } fr
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js'; import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js'; import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore } from './zombies.js'; import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed } from './zombies.js';
// ------------------------------------------------------------- ballistics ---- // ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
@@ -913,6 +913,7 @@ export function update(dt) {
if (G.mode === 'eaten') { // the match is over in the truest sense if (G.mode === 'eaten') { // the match is over in the truest sense
G.idleT += dt; G.idleT += dt;
const was = G.eatenT; G.eatenT += dt; const was = G.eatenT; G.eatenT += dt;
if (G.eatenT < EATEN_BURST) eatenBleed(dt); // phase A: they are still tearing at you
if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
if (G.boomFlash > 0) G.boomFlash -= dt; if (G.boomFlash > 0) G.boomFlash -= dt;
+1 -1
View File
@@ -97,6 +97,6 @@
<p id="build"></p> <p id="build"></p>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=63"></script> <script type="module" src="main.js?v=64"></script>
</body> </body>
</html> </html>
+42 -20
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js'; import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H } from './config.js'; import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, 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, flickTarget } from './gameplay.js'; import { predictLanding, flickTarget } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
@@ -155,9 +155,9 @@ export function proj(x, y, z) {
// ---------------------------------------------------------------- sprites ---- // ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load. // assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 57; // asset cache-buster — bump with main.js?v=N (see the img.src note below) const SPRV = 58; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {}; const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2', for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2',
'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', 'o_idle1','o_idle2','o_bounce','o_bounce2','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2',
'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_crowd2','x_ads', 'x_ump','x_boyrun1','x_boyrun2','x_boyrun3','x_boyrun4','x_crowd','x_crowd2','x_ads',
'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working 'x_boymop1','x_boymop2','x_boymop3','x_boymop4', // the push cycle, used only while he is stopped and working
@@ -293,6 +293,7 @@ function drawMachine(zb) {
} }
} }
const p0 = zb => proj(zb.x, 0, zb.z).s; // projected scale, for effects that need pixels not metres
function drawZombie(zb) { function drawZombie(zb) {
if (zb.variant === 'm') return drawMachine(zb); if (zb.variant === 'm') return drawMachine(zb);
const rk = zb.variant === 'r'; const rk = zb.variant === 'r';
@@ -330,7 +331,9 @@ function drawZombie(zb) {
// single stills. Every skin has its …b twin now (the stride swapped), stepped off the same clock // single stills. Every skin has its …b twin now (the stride swapped), stepped off the same clock
// as the healthy walk, so losing an arm changes how he walks and not whether he walks. // as the healthy walk, so losing an arm changes how he walks and not whether he walks.
const w = 'z_' + zb.variant + 'w'; const w = 'z_' + zb.variant + 'w';
name = SPR[w + 'b'] && stepFrame(zb.walkT * 2.6, 2) ? w + 'b' : w; // gashed shambler, still lurching // Same CADENCE as the healthy walk (which runs four frames at 5.2/s), or a wounded body reads
// as frozen next to the crowd it is walking in — it was stepping at half the rate on two frames.
name = SPR[w + 'b'] && stepFrame(zb.walkT * 5.2, 2) ? w + 'b' : w; // gashed shambler, still lurching
} }
// The boss's damage is read as a FRACTION of full health — his hp scales with the run // The boss's damage is read as a FRACTION of full health — his hp scales with the run
// (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run // (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run
@@ -340,7 +343,7 @@ function drawZombie(zb) {
if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do if (f <= 0.34 && SPR.z_cw2) { name = 'z_cw2'; h = 1.52; } // headless: a head shorter, so the shoulders land where a whole zombie's do
else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1'; else if (f <= 0.67 && SPR.z_cw1) name = 'z_cw1';
const b = name + 'b'; // ...and he limps through his wounds too, instead of gliding const b = name + 'b'; // ...and he limps through his wounds too, instead of gliding
if (name !== 'z_' + v + frame && SPR[b] && stepFrame(zb.walkT * 2.6, 2)) name = b; if (name !== 'z_' + v + frame && SPR[b] && stepFrame(zb.walkT * 5.2, 2)) name = b;
} }
// The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over // The chapter boss comes apart through the SAME fraction-of-hpMax rule as the racket zombie, over
// three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs // three stages, so a 34 hp chapter-4 boss visibly degrades all the way down: arm off, then ribs
@@ -370,7 +373,10 @@ function drawZombie(zb) {
// already existed for the wound stages; it belongs to every frame, and it is the same rule // already existed for the wound stages; it belongs to every frame, and it is the same rule
// _slice.py's common scaling is built on (see HANDOFF: heights follow the PNG). // _slice.py's common scaling is built on (see HANDOFF: heights follow the PNG).
if (SPR[name]) h *= SPR[name].height / SPRITE_H; if (SPR[name]) h *= SPR[name].height / SPRITE_H;
drawStatic(name, zb.x, zb.z, h, Math.floor(zb.walkT * 5.2) % 2); // feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm.
const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05)
: Math.floor(zb.walkT * 5.2) % 2;
drawStatic(name, zb.x, zb.z, h, bob);
if (zb.burnT > 0) drawFlames(zb, h); if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster const k = 1 - zb.windT / TUNE.bossWind, p = proj(zb.x, h * 0.62, zb.z); // bossWind, not machWind: the boss telegraphs faster
@@ -495,6 +501,31 @@ function drawChar(ch) {
// the man. His health reads as bleedPlayer()'s live drip plus the screen-edge wash on impact. // the man. His health reads as bleedPlayer()'s live drip plus the screen-edge wash on impact.
} }
// The death. It used to be a red tint and a shake for half a second, which reads as "the sprite is
// having a moment", not as being eaten. Now: he is dragged to his knees fighting it (p_die1/p_die2
// alternating), then pulled flat and pressed DOWN into the ground as the ring of bodies closes over
// him — he is drawn inside the crowd's own z order (see render()), so the ones between him and the
// camera hide him exactly as they arrive. What is left when he is gone is the mound.
function drawDying() {
const at = G.eatenAt || P, t = G.eatenT;
if (t >= EATEN_BURST) return; // he is the pile now
const struggling = t < EATEN_GRAB;
const name = struggling ? (Math.floor(t * 11) % 2 ? 'p_die1' : 'p_die2') : 'p_die2';
const img = SPR[name];
const p = proj(at.x, 0, at.z);
const sink = struggling ? 0 : Math.min(1, (t - EATEN_GRAB) / (EATEN_BURST - EATEN_GRAB)); // 0..1 going under
const j = struggling ? 1.6 : 0.6; // he stops fighting as he goes down
ctx.save();
ctx.translate((Math.random() * 2 - 1) * j, (Math.random() * 2 - 1) * j * 0.6);
if (img) {
const h = PLAYER_H * p.s * (img.height / SPRITE_H) * (1 - 0.8 * sink); // pressed into the ground
const w = h * img.width / img.height;
drawShadow(p.x, p.y, w * 0.42, h * 0.06, 0.3);
ctx.drawImage(img, p.x - w / 2, p.y - h, w, h);
} else drawFallbackChar(P, p, PLAYER_H * p.s * (1 - 0.8 * sink)); // sprites not loaded: still dies
ctx.restore();
}
function drawFallbackChar(ch, p, h) { // shown only until sprites load function drawFallbackChar(ch, p, h) { // shown only until sprites load
const you = ch.side === 'P'; const you = ch.side === 'P';
const u = h / 16, x = p.x, y = p.y; const u = h / 16, x = p.x, y = p.y;
@@ -837,7 +868,10 @@ export function render() {
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 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); const nearSide = zombies.filter(zb => zb.z > COURT.netZ).map(zb => ({ z: zb.z, f: () => drawZombie(zb) }));
if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue
nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying });
nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
// server idly bouncing the ball while waiting // server idly bouncing the ball while waiting
if (G.phase === 'pretoss') { if (G.phase === 'pretoss') {
@@ -888,19 +922,7 @@ export function render() {
ctx.strokeStyle = `rgba(255,180,170,${(1 - k) * 0.35})`; ctx.lineWidth = 1; ctx.strokeStyle = `rgba(255,180,170,${(1 - k) * 0.35})`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke(); ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.62 * k, p.s * 0.22 * k, 0, 0, 7); ctx.stroke();
} }
// death, in three stages. A: you are still there, thrashing and going red as they crowd in. if (G.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying)
// B (from EATEN_BURST): the sprite is gone and you ARE the gore — playerGore() drops the mound.
// C: the summary fades in over it, so the biggest pile on the court stays visible underneath.
if (G.mode !== 'eaten') drawChar(P);
else if (G.eatenT < EATEN_BURST) {
const k = G.eatenT / EATEN_BURST, j = 1 + k * 3; // the shake grows as they pull you down
ctx.save();
ctx.translate((Math.random() * 2 - 1) * j, (Math.random() * 2 - 1) * j * 0.6);
ctx.filter = `sepia(1) saturate(${2 + 7 * k}) hue-rotate(-25deg) brightness(${1 - 0.25 * k})`;
drawChar(P);
ctx.filter = 'none'; // ponytail: unsupported filter just means no tint — the shake still reads
ctx.restore();
}
if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player if ((G.horde || G.mgT > 0) && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player
ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady
+37 -1
View File
@@ -3,7 +3,7 @@
// your baseline — one crossing it is a point for THEM. // your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART } from './config.js'; import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, EATEN_BURST, EATEN_FEED_R } from './config.js';
import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js';
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js'; import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js'; import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js';
@@ -280,6 +280,21 @@ function burnTick(zb, i, dt) {
return true; return true;
} }
// Phase A, every frame: they are tearing at you and it shows. A thin constant spray out of the
// pile plus a stain that keeps widening — "blood and gore flowing out" is a CONTINUOUS thing, not
// one burst, and the burst at EATEN_BURST lands on top of it.
export function eatenBleed(dt) {
const at = G.eatenAt; if (!at) return;
for (let k = 0; k < 2; k++) parts.push({
x: at.x + (Math.random() * 2 - 1) * 0.35, y: 0.5 + Math.random() * 0.8, z: at.z + (Math.random() * 2 - 1) * 0.3,
vx: (Math.random() * 2 - 1) * 3.2, vy: 1.5 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.2,
t: 0.4 + Math.random() * 0.5, dark: Math.random() < 0.45 });
const g = gore.find(d => d.spreading);
if (g) g.r = Math.min(2.1, g.r + dt * 0.7);
else { gore.push({ x: at.x, z: at.z, r: 0.7, a: 0.9, spreading: true }); if (gore.length > 40) gore.shift(); }
if ((G.eatSfxT = (G.eatSfxT || 0) - dt) <= 0) { splat(0.45); G.eatSfxT = 0.28; } // wet work, on a loop
}
// phase B of the death animation (EATEN_BURST, config.js): the player IS the gore now. Reuses killZombie's machinery — // phase B of the death animation (EATEN_BURST, config.js): the player IS the gore now. Reuses killZombie's machinery —
// the biggest mound on the court, and it stays visible under the summary scrim. That's the point. // the biggest mound on the court, and it stays visible under the summary scrim. That's the point.
export function playerGore() { export function playerGore() {
@@ -555,6 +570,27 @@ export function updateZombies(dt) {
pock(0.3, 1500); // the tell: a mechanical click as it loads pock(0.3, 1500); // the tell: a mechanical click as it loads
} }
} }
if (G.mode === 'eaten' && G.eatenAt && zb.variant !== 'm') {
// You are down. Nothing else matters to them: every body on court converges on the spot and
// packs into a ring around it, and the ones already there feed (the bob is in drawZombie).
// This is the whole point of the death — you go under a CROWD, not under a puff of gore.
// Each one claims a SPOT around the body, kept from wherever it happened to be standing, so
// they form a ring and crowd in from all sides. Sending them all at the one point instead
// stacked every body on the same pixel and read as one fat zombie, not a swarm.
if (zb.eatA === undefined) {
zb.eatA = Math.atan2(zb.z - G.eatenAt.z, zb.x - G.eatenAt.x) + (Math.random() * 2 - 1) * 0.4;
zb.eatR = EATEN_FEED_R * (0.8 + Math.random() * 0.7);
}
const tx = G.eatenAt.x + Math.cos(zb.eatA) * zb.eatR;
const tz = G.eatenAt.z + Math.sin(zb.eatA) * zb.eatR * 0.55; // squashed in depth: the camera is low
const dx = tx - zb.x, dz = tz - zb.z, d = Math.hypot(dx, dz);
zb.feeding = d < 0.12;
if (!zb.feeding) {
const sp = zb.v * 1.8 * dt; // they hurry for a kill
zb.x += dx / d * Math.min(sp, d); zb.z += dz / d * Math.min(sp, d);
}
continue; // no shambling, no eating you twice, no crossing the baseline for a point
}
if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down if (zb.variant === 'm') { // the machine wheels itself around instead of being bolted down
if (!held && G.mode === 'splatter' && !G.interlude) { if (!held && G.mode === 'splatter' && !G.interlude) {
if (zb.rollTo !== undefined) { if (zb.rollTo !== undefined) {