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:
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 |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -253,7 +253,12 @@ export const TUNE = {
|
||||
// stand sitting back down while the applause was still audible.
|
||||
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
|
||||
// desaturated from the original arcade primaries: they were shouting over the artwork.
|
||||
|
||||
+2
-1
@@ -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 { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
|
||||
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 ----
|
||||
// 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
|
||||
G.idleT += 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 (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||||
if (G.boomFlash > 0) G.boomFlash -= dt;
|
||||
|
||||
+1
-1
@@ -97,6 +97,6 @@
|
||||
<p id="build"></p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="main.js?v=63"></script>
|
||||
<script type="module" src="main.js?v=64"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
+37
-1
@@ -3,7 +3,7 @@
|
||||
// your baseline — one crossing it is a point for THEM.
|
||||
// (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 { splat, moan, pock, voice, boom, swish, shriek, aww, sfx } from './audio.js';
|
||||
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside } from './gameplay.js';
|
||||
@@ -280,6 +280,21 @@ function burnTick(zb, i, dt) {
|
||||
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 —
|
||||
// the biggest mound on the court, and it stays visible under the summary scrim. That's the point.
|
||||
export function playerGore() {
|
||||
@@ -555,6 +570,27 @@ export function updateZombies(dt) {
|
||||
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 (!held && G.mode === 'splatter' && !G.interlude) {
|
||||
if (zb.rollTo !== undefined) {
|
||||
|
||||
Reference in new issue
Block a user