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

+42 -20
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
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 { predictLanding, flickTarget } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
@@ -155,9 +155,9 @@ export function proj(x, y, z) {
// ---------------------------------------------------------------- sprites ----
// 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 = {};
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',
'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
@@ -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) {
if (zb.variant === 'm') return drawMachine(zb);
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
// as the healthy walk, so losing an arm changes how he walks and not whether he walks.
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
// (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
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
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
// 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
// _slice.py's common scaling is built on (see HANDOFF: heights follow the PNG).
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.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
@@ -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 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
const you = ch.side === 'P';
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);
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);
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
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.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.
// 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.mode !== 'eaten') drawChar(P); // ...the dying man is drawn with the crowd instead (drawDying)
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