diff --git a/HANDOFF.md b/HANDOFF.md index cdf6c48..98d80bc 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -358,6 +358,45 @@ trail only says where it has been. `jet`/`trailR` were added to the WEAPONS rows without being added to that list — so the rocket flew with no plume at all and nothing errored. **Any new pellet field has to be added in both places.** +## Session log: fire, ash, and the third laser pass (2026-08-03) + +**A BURNT BODY DOES NOT BURST.** Fire kills by cooking, so there is nothing left to spray: a body +that dies while alight — flamethrower or flame ball — leaves **dark grey ash** instead of blood. Same +decal, same mound, same particles, all recoloured and a little smaller, plus a puff of smoke and a +dull thud instead of the wet splat and the headshot crack. `killZombie` reads `zb.burnT > 0 || +zb.ashes`; **the flame BALL has to set `zb.ashes` itself**, because that kill happens before the body +ever catches. `gore`/`piles`/`parts` all carry an `ash` flag and render off `ASH_COLS`. + +**The flamethrower is a lighter, not a gun.** `dmg: 0, hp: 0, pierce: true` — it does NO impact +damage at all and only IGNITES; the burn does the killing, so a lit crowd walks a few steps and goes +down together. Reach `life` 1.15 s x `speed` 23 = **26 m against a 22.8 m court**, so it crosses to +the far baseline. Watch the guard in the pellet loop: `hp -= 0` fell straight through to +`killZombie`, which made the flame cone delete the crowd on contact — a machine gun that happens to +be orange. + +**Every burst weapon had no progress bar, and the cause was the spend.** Arming spent the tin +immediately, so the rack entry vanished — and the countdown bar draws on the slot holding `G.gunId`, +which no longer existed. The tin is spent when the burst ENDS now (`endBurst`), so the icon, the +count and the bar are all there while it runs. **Every path that ends a burst must route through +`endBurst`** — the duel, the boss fight and death all force `G.mgT = 0` on their own, and any one of +them missed would hand out free ammo. The soak asserts `gunId` is never set with `mgT` at zero. + +**Auto-fire movement, again.** The previous fix set the pose from `moving`, but `moving` was +`mx || mz || assist`, and finger-steering zeroes `mx`/`mz` — so a player sliding left and right under +auto-fire still counted as standing still and never ran. `mgMoving` is in that expression now. + +**The oval on his back was the mg racket.** `drawMgRacket` put its head at `(P.x, 0.95, P.z - 1.35)`, +which at this camera projects almost exactly onto the player sprite. Offset by `P.facing` it reads as +a racket held out beside him. + +**The laser's far edge is pinned in SCREEN space.** Running out to `beamLen` on the ground landed it +on the ads' TOP line; it should die at their middle. `c3.y = c4.y = proj(0,0,-6).y - AD_H / 2` — the +x's keep their perspective, the y is pinned. That holds in both orientations and leaves `beamLen` +free to be the KILL range instead of a number tuned to one camera. + +**Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket, +`whoosh`), otherwise the gun's every-other-round tick. + ## TEST MODE — triple-tap the scoreboard A sandbox for trying new content without playing twenty games to reach it. Three taps on the diff --git a/config.js b/config.js index 07d2aa0..45b1367 100644 --- a/config.js +++ b/config.js @@ -465,12 +465,16 @@ export const WEAPONS = { // shot and dragging its colour behind it. The showiest thing in the game and the one you meet first. beacon: { label: 'BEACON', icon: 'w_beacon', tin: 1, kind: 'burst', len: 11, rate: 1.6, r: 1.2, dmg: 2, hp: 4, arc: 1, hue: 1, trail: 1, trailR: 0.035, glow: 3.2, speed: 19, + sfx: 'thump', // it LOBS — a soft low thump as it goes, not a rifle crack desc: ['IT THROWS ITSELF', 'TWICE THE BALL, EVERY COLOUR'] }, // "make this one way more pronounced": twice the rounds a second, a wider and longer cone, fat // glowing rounds that throw their own embers, and every one of them lights what it touches. + // It REACHES THE FAR BASELINE (life 1.15 s x 23 m/s = ~26 m), it PIERCES, and it does no impact + // damage at all: dmg/hp 0. Fire is not a bullet — being hit sets you alight and the burn does the + // killing, so a lit crowd walks a few steps and goes down together. That is the whole weapon. flamer: { label: 'FLAMER', icon: 'w_flamer', tin: 1, kind: 'burst', len: 6, rate: 30, - r: 1.15, dmg: 2, hp: 9, speed: 15, life: 0.7, ignite: true, igniteR: 2.6, - trail: 2, col: '#ff8420', glow: 2, spread: 3.1, // life 0.7s x 15 m/s = a ~10 m cone + r: 1.15, dmg: 0, hp: 0, pierce: true, speed: 23, life: 1.15, ignite: true, igniteR: 2.6, + trail: 2, col: '#ff8420', glow: 2, spread: 3.1, sfx: 'flame', desc: ['A WALL OF FIRE', 'AND THE FIRE SPREADS'] }, // The normal ball gesture, made spectacular: pull, aim, release — and instead of a ball you get a // blue plane that widens down the court and cuts through everything standing in it. @@ -483,7 +487,7 @@ export const WEAPONS = { // Lobbed, self-throwing, and it detonates like a bomb where it lands. Deliberately a LATE pickup // (see pickBarrelWeapon) — Oliver: "best later in the game when there is really a lot going on". rocket: { label: 'ROCKET', icon: 'w_rocket', tin: 1, kind: 'burst', len: 9, rate: 1.1, - r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, jet: 3, speed: 38, + r: 0.75, dmg: 3, hp: 9, arc: 1, boom: true, jet: 3, speed: 38, sfx: 'swoosh', col: '#ffd050', glow: 1.8, desc: ['IT LAUNCHES ITSELF', 'AND EVERY ONE IS A BOMB'] }, }; diff --git a/gameplay.js b/gameplay.js index e924b19..72f13bb 100644 --- a/gameplay.js +++ b/gameplay.js @@ -592,7 +592,7 @@ export function startDuel() { if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel G.weapon = 0; // tennis rules for the duel: plain balls, no tins - G.mgT = 0; pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order. + G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order. G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration) ball.active = false; setupPoint(); @@ -614,7 +614,7 @@ export function startBossFight(chapter) { if (G.bossFight) return; G.bossFight = chapter; zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage - G.horde = null; G.mgT = 0; G.clearing = false; + G.horde = null; G.mgT = 0; endBurst(); G.clearing = false; ball.active = false; setupPoint(); spawnChapterBoss(chapter); zombieDuck(true); // the music drops out for it @@ -934,7 +934,10 @@ function tryArmGun() { if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false; G.gunId = id; G.mgT = w.len ?? TUNE.mgPickupLen; - spendSlot(G.weapon); + // The tin is spent when the burst ENDS, not when it starts. Spending on commit removed the rack + // entry immediately, so the countdown bar — which draws on the slot holding G.gunId — had no slot + // to draw on and every burst weapon ran with no progress bar at all. Oliver spotted it on the + // flamethrower; it was true of all four. G.mgCool = 0; ball.active = false; G.phase = 'rally'; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; @@ -953,6 +956,15 @@ export function serveToss() { if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P); } +// The burst is over: NOW the tin is spent. Called from updateZombies when mgT runs out. +export function endBurst() { + const id = G.gunId; + G.gunId = null; + if (!id) return; + const slot = [0, 1, 2, 3, 4].find(i => slotId(i) === id); + if (slot !== undefined) spendSlot(slot); +} + // The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction // and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim // if there is one and otherwise fires straight up-court. @@ -1168,12 +1180,11 @@ export function update(dt) { // to what the camera can see (portrait's world bounds are wider than its viewport) const mgMode = G.horde || G.mgT > 0; const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); - let mgSteer = false; + let mgSteer = false, mgMoving = false; if (mgMode && touchPX() !== null) { mgSteer = true; const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis)); - if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; P.runT += dt; P.pose = 'run'; } - else P.pose = 'idle'; + if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; mgMoving = true; } P.x = tx; } const slow = P.swingT >= 0 ? 0.35 : 1; @@ -1202,7 +1213,10 @@ export function update(dt) { const xLim = Math.max(xVis, COURT.W / 2); P.x = Math.max(-xLim, Math.min(xLim, P.x)); P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); - const moving = mx || mz || assist !== 0; + // mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a + // player sliding left and right under auto-fire counted as standing still and never ran — + // "when he's moving left and right the run animation is not applied". + const moving = mgMoving || mx || mz || assist !== 0; P.runT += moving ? dt : 0; // Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then // faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic diff --git a/index.html b/index.html index 6e25eee..260ac12 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/render.js b/render.js index 00c42c0..4e9d83f 100644 --- a/render.js +++ b/render.js @@ -599,6 +599,11 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) { oz + uz * (u * TUNE.beamLen) + pz * w); const u0 = Math.max(0, from); const rgb = RGB(WEAPONS.laser.col); + // WHERE THE FAR EDGE SITS is a screen-space decision, not a world one. Run out to beamLen on the + // ground and it lands on the ads' TOP line; the beam should die at their MIDDLE. Pinning the far + // corners' y here keeps that exact in both orientations and leaves beamLen free to be the KILL + // range rather than a number tuned to one camera. + const farY = proj(0, 0, -6).y - AD_H / 2; // proj(0,0,-6) is the same line the ad strip sits on // THREE NESTED WEDGES: a wide violet-blue bloom, a cyan body, and a white-hot core down the // middle. One flat quad with a gradient along its length was a coloured shape; layers that share // an axis and get brighter toward the centre are what read as sci-fi light. @@ -613,6 +618,7 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) { for (const L of layers) { const w0 = wAt(u0) * L.k, w1 = wAt(1) * L.k; const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1); + c3.y = c4.y = farY; // ...the x's keep their perspective, the y is pinned // ...and it fades ALONG its length too, so the far end dissolves into the ads instead of // stopping at a hard edge const g = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2, @@ -676,13 +682,18 @@ function mulberry32(a) { return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } const PILE_COLS = ['#6e1414', '#8c1212', '#4e0e0e', '#7a2a18']; +// ...and the same mound for something the fire got: cold, dry, grey. A body that burns has nothing +// left to spray, so it should not leave a wet red heap behind it. +const ASH_COLS = ['#4a4744', '#3a3835', '#5c5854', '#2e2c2a']; function buildBlobs(pl) { const rnd = mulberry32((pl.seed | 0) + 1), n = Math.round(6 + pl.size * 5); pl.blobs = []; for (let k = 0; k < n; k++) { const ang = rnd() * 6.2832, rad = Math.sqrt(rnd()); // uniform over the disk pl.blobs.push({ nx: Math.cos(ang) * rad, nz: Math.sin(ang) * rad, - lift: rnd(), pr: 1 + rnd() * 2, color: rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0] }); + lift: rnd(), pr: 1 + rnd() * 2, + color: pl.ash ? (rnd() < 0.12 ? '#8a8580' : ASH_COLS[(rnd() * 4) | 0]) // a few pale flecks of bone + : (rnd() < 0.1 ? '#cfc9b0' : PILE_COLS[(rnd() * 4) | 0]) }); } pl.blobs.sort((a, b) => (b.nz - 1.25 * b.lift) - (a.nz - 1.25 * a.lift)); // back/low first, peak on top pl.builtSize = pl.size; @@ -799,8 +810,12 @@ function drawFallbackChar(ch, p, h) { // shown only until sprites load ctx.fillStyle = skin; ctx.fillRect(x - 1.8 * u, y - 3 * u, 1.4 * u, 3 * u); ctx.fillRect(x + 0.4 * u, y - 3 * u, 1.4 * u, 3 * u); } function drawMgRacket() { // horde: the player braces the racket forward and up-court, machine-gun style - const hip = proj(P.x + 0.15, 1.05, P.z); - const tip = proj(P.x, 0.95, P.z - 1.35); // pointing up-court, where the pellets stream out + // HELD OUT TO ONE SIDE. Straight up-court (P.x, P.z - 1.35) projects to almost exactly where the + // player sprite already is, so the racket head landed as an oval painted on his back — Oliver saw + // it every time. Offset by facing, it reads as a racket held out beside him. + const f = P.facing || 1; + const hip = proj(P.x + 0.34 * f, 1.15, P.z); + const tip = proj(P.x + 0.86 * f, 1.02, P.z - 1.25); // pointing up-court, where the pellets stream out ctx.strokeStyle = '#d8d8d8'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(hip.x, hip.y); ctx.lineTo(tip.x, tip.y); ctx.stroke(); ctx.strokeStyle = '#b07030'; ctx.lineWidth = 1.5; // the head, aimed forward @@ -1072,7 +1087,9 @@ export function render() { // gore decals: what remains of the visiting team (flat ground layer, then far-side mounds) for (const d of gore) { const p = proj(d.x, 0, d.z); - ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` : `rgba(140,18,18,${d.a})`; // craters burn black-brown + ctx.fillStyle = d.scorch ? `rgba(28,22,16,${d.a})` + : d.ash ? `rgba(58,56,54,${d.a})` // burnt out: dark grey ash, not blood + : `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(); } drawPiles(false); // far-side piles (occluded by the net); near-side piles drawn after the net @@ -1197,6 +1214,11 @@ export function render() { ctx.globalAlpha = 1; continue; } + if (pt.ash) { // settling ash from a burnt body + ctx.fillStyle = pt.dark ? 'rgba(46,44,42,0.9)' : 'rgba(92,88,84,0.9)'; + ctx.fillRect(pp.x | 0, pp.y | 0, 2, 2); + continue; + } ctx.fillStyle = pt.spark ? pt.spark // a thrown round dragging its colour : pt.pink ? (pt.t > 0.2 ? UI.pink : '#ff9df6') // a barrel bursting: debris, not gore : pt.fire ? (pt.t > 0.12 ? '#ffc030' : '#ff8010') diff --git a/zombies.js b/zombies.js index 071f425..5948c1a 100644 --- a/zombies.js +++ b/zombies.js @@ -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