diff --git a/assets/_sheet_pdeath.png b/assets/_sheet_pdeath.png new file mode 100644 index 0000000..3c98e8b Binary files /dev/null and b/assets/_sheet_pdeath.png differ diff --git a/assets/p_die1.png b/assets/p_die1.png new file mode 100644 index 0000000..1d9c619 Binary files /dev/null and b/assets/p_die1.png differ diff --git a/assets/p_die2.png b/assets/p_die2.png new file mode 100644 index 0000000..e6f2f04 Binary files /dev/null and b/assets/p_die2.png differ diff --git a/config.js b/config.js index 93b55a4..0856beb 100644 --- a/config.js +++ b/config.js @@ -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. diff --git a/gameplay.js b/gameplay.js index 678e8e1..1ddfba8 100644 --- a/gameplay.js +++ b/gameplay.js @@ -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; diff --git a/index.html b/index.html index 4ef5baa..051f9d6 100644 --- a/index.html +++ b/index.html @@ -97,6 +97,6 @@

- + diff --git a/render.js b/render.js index cc50190..33d0071 100644 --- a/render.js +++ b/render.js @@ -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/.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 diff --git a/zombies.js b/zombies.js index 4bafe80..82cab18 100644 --- a/zombies.js +++ b/zombies.js @@ -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) {