Burn them to ashes, and give the burst back its bar

A burnt body does not burst. Fire kills by cooking, so there is nothing left to
spray: a body that dies alight -- flamethrower or flame ball -- leaves dark grey
ash instead of blood. Same decal, mound and particles, recoloured and smaller,
with a puff of smoke and a dull thud instead of the wet splat and the headshot
crack. The flame BALL has to mark itself, because that kill lands before the body
ever catches.

The flamethrower is a lighter, not a gun: no impact damage at all, it pierces,
and it only IGNITES -- the burn does the killing, so a lit crowd walks a few
steps and goes down together. 26 m of reach against a 22.8 m court, so it
crosses to the far baseline. The guard matters: hp -= 0 fell straight through to
killZombie, which made the cone delete the crowd on contact.

Every burst weapon had no progress bar and the cause was the spend. Arming spent
the tin at once, so the rack entry vanished -- and the bar draws on the slot
holding G.gunId, which no longer existed. The tin is spent when the burst ENDS
now, so icon, count and bar are all there while it runs, and every path that
ends a burst routes through endBurst or it would hand out free ammo.

Auto-fire movement again: the previous fix read moving as mx||mz||assist, and
finger-steering zeroes mx/mz, so sliding left and right still counted as standing
still. And the oval on his back was the mg racket -- its head projected almost
exactly onto the sprite; offset by facing it reads as held out beside him.

The laser's far edge is pinned in SCREEN space to the middle of the ad strip
rather than running out to beamLen on the ground, which landed on their top line.
beamLen goes back to being the kill range instead of a number tuned to one camera.

Beacon thumps, rocket swooshes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 15:05:56 +02:00
1 parent 91053b4c35
commit 6e88fd5bb9
6 files changed
+128 -30

No files matched your search

+34 -15
View File
@@ -5,8 +5,8 @@
import { TUNE, UI, COURT, VOLLEY, CONTACT, BOSS_ART, WEAPONS, BARREL_WEAPONS, woundStage, EATEN_BURST, EATEN_SUMMARY, 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, grantWeapon, weaponModal , dragContact } from './gameplay.js';
import { splat, moan, pock, voice, boom, swish, shriek, aww, sfx , whoosh } from './audio.js';
import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal , dragContact , endBurst } from './gameplay.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
@@ -232,7 +232,7 @@ export function killPlayer() {
// the death animation runs off G.eatenT: thrash here, burst at EATEN_BURST (playerGore), summary after
G.eatenT = 0; G.eatenAt = { x: P.x, z: P.z };
G.mode = 'eaten'; ball.active = false; G.flash = null; G.modal = null; // zombies keep shambling for ambience
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; // everything stops
G.horde = null; pellets.length = 0; extras.length = 0; shots.length = 0; G.mgT = 0; G.gunId = null; // everything stops
try { // the run is over — settle it against the best (games survived, sets as tiebreak)
const b = JSON.parse(localStorage.splatterBest || 'null') || { games: 0, sets: 0 };
G.newBest = G.runGames > b.games; // beat it → the summary crows about it
@@ -396,24 +396,33 @@ function killZombie(i, quiet) {
}
if (G.interlude && zb.variant === 'r') resolveInterlude('P'); // regicide ends the duel your way — any weapon counts
if (zb.variant === 'B') endBossFight(); // the chapter is over
// A BURNT BODY DOES NOT BURST. Fire kills by cooking, so there is nothing left to spray — it goes
// to dark grey ash instead of blood: same decal, same mound, same particles, all recoloured and a
// little smaller, plus a puff of smoke. Marked either by still burning or by `ashes`, which the
// flame ball sets before it kills (that kill happens before the body ever catches).
const ash = zb.burnT > 0 || zb.ashes;
const stains = Math.round(Math.sqrt(mul)); // advanced kills splash a scatter of stains, not one puddle
for (let k = 0; k < stains; k++) {
gore.push({ x: zb.x + (k ? (Math.random() * 2 - 1) * 0.9 : 0), z: zb.z + (k ? (Math.random() * 2 - 1) * 0.9 : 0),
r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul), a: 0.85 });
r: (0.45 + Math.random() * 0.35) * Math.sqrt(mul) * (ash ? 0.8 : 1), a: ash ? 0.7 : 0.85, ash });
if (gore.length > 40) gore.shift();
}
const pl = piles.find(p => Math.hypot(p.x - zb.x, p.z - zb.z) < 0.6); // stack up where kills cluster
if (pl) pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5));
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0 });
if (pl) { pl.size = Math.min(4, pl.size + Math.round(mul / 2 + 0.5)); if (!ash) pl.ash = false; }
else { piles.push({ x: zb.x, z: zb.z, size: Math.min(4, Math.round(mul / 2 + 0.5)), seed: Math.random() * 1e4, blobs: null, builtSize: 0, ash });
if (piles.length > 30) piles.shift(); }
const n = Math.round(30 * mul); // base bumped 24 -> 30, then the multiplier goes to work
const n = Math.round((ash ? 18 : 30) * mul); // ash settles rather than sprays: fewer, slower motes
for (let k = 0; k < n; k++) { const dark = k >= n * 2 / 3; parts.push({ // ~1/3 dark & long-lived: settling gore
x: zb.x, y: 0.9, z: zb.z,
vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 3.5, vz: (Math.random() * 2 - 1) * 3.5,
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark,
vx: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5), vy: (ash ? 0.6 : 1) + Math.random() * (ash ? 1.6 : 3.5),
vz: (Math.random() * 2 - 1) * (ash ? 1.6 : 3.5),
t: dark ? 0.6 + Math.random() * 0.6 : 0.4 + Math.random() * 0.4, dark, ash,
}); }
splat();
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
if (ash) { for (let k = 0; k < 5; k++) smokePuff(zb.x, 0.4 + Math.random() * 1.1, zb.z, 0.45); pock(0.35, 150); }
else {
splat();
sfx('headshot-' + (Math.random() < 0.5 ? 1 : 2), 0.6); // random headshot crack layered on the gore burst
}
sendBoy(zb.x, zb.z, true); // a boy comes out with the mop
// The chapter boss deliberately scores NOTHING: its reward is the chapter card and the parked game
// beat resuming. Letting it bank a point meant splatPoint's game-over branch and endBossFight both
@@ -591,7 +600,11 @@ export function firePellet(w = WEAPONS.gun) {
// it is running from the corner of your eye.
if (w.hue) pe.col = HUES[mgTick % HUES.length];
pellets.push(pe);
if (++mgTick % 2 === 0 || w.arc) pock(0.25, w.boom ? 420 : w.arc ? 900 : w.ignite ? 300 : 1400);
// each weapon announces itself: a low THUMP as the beacon lobs, a SWOOSH as a rocket leaves,
// and the gun's rapid tick every other round (at 12+/s every round buzzes the mixer)
if (w.sfx === 'thump') boom(0.3);
else if (w.sfx === 'swoosh') whoosh(0.3);
else if (++mgTick % 2 === 0) pock(0.25, w.ignite ? 300 : 1400);
}
const HUES = ['#ff6b6b', '#ffd166', '#7bffa0', '#6bd5ff', '#c08bff', '#ff8bd5'];
// the best thing up-court to lob at: nearest body ahead of you, ignoring furniture you can walk past
@@ -730,7 +743,7 @@ export function updateZombies(dt) {
// whichever gun-like weapon is armed sets the rate and the round (see config.WEAPONS). A
const gw = WEAPONS[G.gunId] || WEAPONS.gun;
if (G.phase === 'rally' && gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; }
if (G.mgT <= 0) setupPoint(); // ammo dry — the serve loop resumes
if (G.mgT <= 0) { endBurst(); setupPoint(); } // window over: spend the tin, resume the serve loop
}
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns)
spawnT -= dt;
@@ -892,6 +905,7 @@ export function updateZombies(dt) {
continue;
}
const vx0 = ball.vx, vz0 = ball.vz, bx = zb.x, bz = zb.z;
zb.ashes = true; // burnt, not burst — see killZombie
splatter(i); ball.vx = vx0; ball.vz = vz0; // undo splatter's pierce tax: full pace through the lane
ignite(bx, bz, TUNE.burnIgniteR); // ...and everyone standing near the body goes up
continue;
@@ -985,8 +999,13 @@ export function updateZombies(dt) {
// EVERY new weapon comes through here, which is why none of them can silently no-op on a
// boss the way three of the five once did.
if (pe.ignite) ignite(pe.x, pe.z, pe.ignite); // the flamer lights the spot, and fire spreads itself
if (isBoss(zb)) { if (wound(zb, pe.dmg ?? TUNE.dmgPellet, false)) killZombie(j, true); else pock(0.45, 240); }
else if ((zb.hp -= (pe.hp ?? TUNE.dmgPelletHp)) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
// A round with no impact damage (the flamethrower) only LIGHTS what it touches — the burn
// does the killing. Without this guard `hp -= 0` fell straight through to killZombie and the
// flame cone deleted the crowd on contact, which is a machine gun that happens to be orange.
const dmgHp = pe.hp ?? TUNE.dmgPelletHp, dmgBoss = pe.dmg ?? TUNE.dmgPellet;
if (isBoss(zb)) { if (dmgBoss > 0 && wound(zb, dmgBoss, false)) killZombie(j, true); else pock(0.45, 240); }
else if (dmgHp <= 0) { /* fire only: it is already alight from the ignite above */ }
else if ((zb.hp -= dmgHp) > 0) pock(0.5, 260); // only brutes soak now; a shambler drops to one round
else killZombie(j, true);
if (pe.boom) { explode(pe.x, pe.z); hit = true; break; } // the rocket takes the crowd, not the body
hit = !pe.pierce; // a laser keeps going down the whole lane