Phase 8: bomb juice — the explosion is finally FELT

- Camera shake (G.shake, decays over 0.35s; HUD stays steady via transform reset)
- One white flash frame (G.boomFlash overlay)
- Scorch crater: near-black stain the boys mop 10x slower — the lawn scars
- Hitstop 0.1 -> 0.18
- Umpire loses composure: 'Order. Order, please.' (new order.m4a), rate-limited
  so bomb chains don't spam him

Verified: explode kills cluster of 4 (MASSACRE x4 chains), shake/flash set and
decay clean, 2nd bomb order-call suppressed, 1s of mopping erases blood (0.9->0)
but not the crater (1->0.84); screenshot mid-shake; no console errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-12 09:57:31 +02:00
1 parent 5a7f71fdba
commit a3bd45e2f1
5 files changed
+19 -3

No files matched your search

Binary file not shown.
+2
View File
@@ -467,6 +467,8 @@ export function update(dt) {
return; return;
} }
if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; } if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; }
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
if (G.boomFlash > 0) G.boomFlash -= dt;
crowd.cheer = Math.max(0, crowd.cheer - dt); crowd.cheer = Math.max(0, crowd.cheer - dt);
G.idleT += dt; G.idleT += dt;
const w = BOUNCE_IDLE.tempo; const w = BOUNCE_IDLE.tempo;
+8 -1
View File
@@ -233,6 +233,10 @@ function drawMgRacket() { // horde: the player braces the racket forward and up-
// ------------------------------------------------------------------ frame ---- // ------------------------------------------------------------------ frame ----
export function render() { export function render() {
ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density
if (G.shake) { // bomb kick: jitter the whole world (transform is reset before the HUD)
const m = G.shake.mag * G.shake.t / 0.35;
ctx.translate((Math.random() * 2 - 1) * m, (Math.random() * 2 - 1) * m);
}
const pal = palette(); const pal = palette();
// stands: tiered crowd from top of screen down to just above the far court // stands: tiered crowd from top of screen down to just above the far court
@@ -271,7 +275,7 @@ export function render() {
// gore decals: what remains of the visiting team (flat ground layer, then far-side mounds) // gore decals: what remains of the visiting team (flat ground layer, then far-side mounds)
for (const d of gore) { for (const d of gore) {
const p = proj(d.x, 0, d.z); const p = proj(d.x, 0, d.z);
ctx.fillStyle = `rgba(140,18,18,${d.a})`; ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` : `rgba(140,18,18,${d.a})`; // craters burn black-brown
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(); 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 drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net
@@ -328,6 +332,9 @@ export function render() {
if (G.mode !== 'eaten') drawChar(P); // there is no P anymore if (G.mode !== 'eaten') drawChar(P); // there is no P anymore
if (G.horde && G.mode === 'splatter') drawMgRacket(); // rambo racket over the player if (G.horde && 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
if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,255,255,${Math.min(1, G.boomFlash / 0.08) * 0.85})`; ctx.fillRect(0, 0, W, H); }
// HUD — scoreboard // HUD — scoreboard
ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30); ctx.fillStyle = 'rgba(12,16,24,0.82)'; ctx.fillRect(6, 6, 132, 30);
ctx.strokeStyle = '#5a6a85'; ctx.strokeRect(6.5, 6.5, 131, 29); ctx.strokeStyle = '#5a6a85'; ctx.strokeRect(6.5, 6.5, 131, 29);
+3
View File
@@ -21,6 +21,9 @@ export const G = {
overT: 0, overT: 0,
hitstop: 0, hitstop: 0,
flash: null, // {text, t} flash: null, // {text, t}
shake: null, // {t, mag} — camera shake, decays like flash (bomb impact)
boomFlash: 0, // s of white flash frame left (bomb impact)
lastOrderT: -9, // idleT of the last 'Order' call — bomb chains mustn't spam the umpire
idleT: 0, idleT: 0,
outMarker: null, // {x, z, out, t} — where your last shot lands outMarker: null, // {x, z, out, t} — where your last shot lands
boy: null, // ball boy sprinting across: {x, z, dir, runT} boy: null, // ball boy sprinting across: {x, z, dir, runT}
+6 -2
View File
@@ -101,6 +101,7 @@ function tickHorde(dt) {
export function explode(x, z) { export function explode(x, z) {
for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices the array for (let i = zombies.length - 1; i >= 0; i--) // backwards: splatter() splices the array
if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i); if (Math.hypot(zombies[i].x - x, zombies[i].z - z) < TUNE.bombRadius) splatter(i);
gore.push({ x, z, r: 1.6, a: 1, scorch: true }); if (gore.length > 40) gore.shift(); // the crater — near-permanent
gore.push({ x, z, r: 1.4, a: 0.9 }); if (gore.length > 40) gore.shift(); gore.push({ x, z, r: 1.4, a: 0.9 }); if (gore.length > 40) gore.shift();
const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6); const pl = piles.find(p => Math.hypot(p.x - x, p.z - z) < 0.6);
if (pl) pl.size = Math.min(4, pl.size + 3); if (pl) pl.size = Math.min(4, pl.size + 3);
@@ -109,7 +110,10 @@ export function explode(x, z) {
x, y: 0.6, z, vx: (Math.random() * 2 - 1) * 6, vy: 1 + Math.random() * 5, vz: (Math.random() * 2 - 1) * 6, x, y: 0.6, z, vx: (Math.random() * 2 - 1) * 6, vy: 1 + Math.random() * 5, vz: (Math.random() * 2 - 1) * 6,
t: 0.5 + Math.random() * 0.7, dark: k % 2 === 0, t: 0.5 + Math.random() * 0.7, dark: k % 2 === 0,
}); });
G.hitstop = 0.1; boom(); G.hitstop = 0.18; boom();
G.shake = { t: 0.35, mag: 6 }; // camera kick
G.boomFlash = 0.08; // one white frame
if (G.idleT - G.lastOrderT > 3) { voice('order', 0.35); G.lastOrderT = G.idleT; } // the umpire loses composure
} }
export function updateZombies(dt) { export function updateZombies(dt) {
@@ -200,7 +204,7 @@ export function updateZombies(dt) {
const boy = G.boy; // the boy mops any gore he passes over const boy = G.boy; // the boy mops any gore he passes over
for (let i = gore.length - 1; i >= 0; i--) { for (let i = gore.length - 1; i >= 0; i--) {
const d = gore[i]; const d = gore[i];
if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * 1.6; if (boy && Math.abs(boy.x - d.x) < 0.7 && Math.abs(boy.z - d.z) < 1.2) d.a -= dt * (d.scorch ? 0.16 : 1.6); // craters scar the lawn 10× longer
if (d.a <= 0) gore.splice(i, 1); 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 for (let i = piles.length - 1; i >= 0; i--) { // piles shave far slower than stains — net growth wins