From 337def8526bc9a94a0b4f17d97cd8d1441681d6d Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Mon, 3 Aug 2026 15:48:08 +0200 Subject: [PATCH] Cut them with the laser plane instead of painting over them The beam is a real plane now, holding TUNE.beamY (1.15 m) all the way out instead of sloping to the ground. That one change is what makes the rest possible: sloping, it lay flat on the grass across the whole far half, so there was nothing to cut exactly where the crowd stands. One flat translucent triangle, apex at the player's CHEST, opening out down the court with the alpha falling away. It was four nested wedges composited 'lighter' and that layering WAS the banding -- additive light blowing out to white where the layers overlapped. Flat alpha also means a body under the plane is tinted rather than brightened, which is what lets the cut read at all. Bodies are sliced by it: the part above the plane is redrawn clipped so it sits on top, the part below stays tinted underneath, and it happens while AIMING as well as on the shot. Measured, the plane takes a shambler at 64% of its height, a brute at 43% and the roller man at 36% -- bigger bodies visibly take it lower. The cut line follows the SPRITE rather than a rectangle. A fillRect spanned the body's bounding width, so it crossed the gaps between the legs and under a held-out arm and read as a bar lying behind him; the line is the sprite itself, redrawn clipped to a thin band and flattened to one colour by ctx.filter. It thins with distance on s squared, because this camera's scale only spans about 1.5x across the court and a linear falloff was invisible. Aiming a filter at a specific colour turned out to be a measurement, not a guess: brightness(0) invert(1) goes to WHITE, and sepia adds almost no chroma to white, so the first chain rendered a grey. Leaving a mid grey for sepia to tint lands on (32,154,255) against the laser icon's (29,153,232). The probe fills a rect and reads the pixel back. The pass is bounded to the eight nearest bodies above 14 px, because it draws each one twice more through a filter and runs every frame you are aiming. 0.27 ms/frame with 20 bodies on desktop, against a 16.7 ms budget. Co-Authored-By: Claude Opus 5 --- HANDOFF.md | 35 ++++++++++ config.js | 9 +++ index.html | 2 +- render.js | 183 +++++++++++++++++++++++++++++++++++++---------------- zombies.js | 7 +- 5 files changed, 176 insertions(+), 60 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index a081050..ebcfc73 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -418,6 +418,41 @@ 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. +## The laser plane, and cutting bodies with it (2026-08-03) + +Settled with Oliver in discussion, then built. **The beam is a real plane at `TUNE.beamY` (1.15 m)** +that holds that height all the way out instead of sloping to the ground — that single change is what +makes the rest possible. Sloping, it lay flat on the grass across the whole far half, so there was +nothing for it to cut exactly where the crowd stands. + +- **One flat translucent triangle**, apex at the player's CHEST (`beamOriginY` 1.25 — emitted from + the racket head at 2.2 m the apex hung in the air and read as detached), opening out down the + court, alpha falling with distance. It was four nested wedges composited `lighter`, and that + layering WAS the banding: additive light blowing out to white where layers overlapped. Flat alpha + also means a body under the plane is TINTED rather than brightened, which is what makes the cut read. +- **Bodies are sliced by it.** `drawBeamCuts` runs after the beam: for each body in the cone, the + part above `proj(zb.x, beamY, zb.z).y` is redrawn clipped, so it sits ON TOP of the plane while the + part below stays tinted underneath. `drawZombie` mutates nothing, so drawing it twice is safe. + Measured: the plane takes a shambler at 64% of its height (sternum), a brute at 43% (belly), the + roller man at 36% (thigh) — bigger bodies visibly take it lower. +- **The cut line follows the SPRITE, not a rectangle.** A `fillRect` spanned the body's bounding + width and so crossed the empty gaps between the legs and under a held-out arm, reading as a bar + lying behind him. The line is the sprite itself, redrawn clipped to a thin band and recoloured flat + by `ctx.filter`. Thickness scales with `h * s²` so it is a hairline at the ads and a real band at + your feet — the camera's scale only spans ~1.5x across the court, so a linear falloff was invisible. +- **Getting a filter to a specific colour is a MEASUREMENT, not a guess.** The chain has to leave a + MID grey for `sepia` to tint: `brightness(0) invert(1)` goes to white, and sepia adds almost no + chroma to white while saturating a colourless pixel does nothing — the first attempt rendered + #a0b4c3, a grey. `brightness(0) invert(0.45) sepia(1) saturate(1600%) hue-rotate(185deg)` renders + (32,154,255) against the laser icon's (29,153,232). Probe candidate chains by filling a rect and + reading the pixel back; do not eyeball it. +- **`HAS_FILTER` is feature-detected** and the line falls back to a narrow bar without it (older + WebKit has no `ctx.filter`). +- **The cut pass is BOUNDED** (`CUT_MAX` 8 nearest bodies, `CUT_MIN_PX` 14): it draws each body twice + more and the second pass runs through a filter, which is slow on iOS, and it runs every frame you + are AIMING rather than only on the shot. Measured on desktop: 0.27 ms/frame with 20 bodies against + a 16.7 ms budget. + ## 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 45b1367..25605e3 100644 --- a/config.js +++ b/config.js @@ -243,6 +243,15 @@ export const TUNE = { beamLen: 34, // m it reaches. Was 26, which stopped at the far baseline and read as the beam // being CUT OFF in mid-court; the player stands at z~24, so this carries it past // the baseline and up into the courtside ads, which is where light should stop. + beamY: 1.15, // m — the height the plane HOLDS down the court, and the line it cuts bodies at. + // It used to slope to the ground by about 20 m out, which meant that across the + // whole far half it lay flat on the grass: nothing to cut, and the split would + // have been invisible exactly where the crowd stands. Chest on a shambler, belly + // on a brute, thigh on the roller man — so a bigger body visibly takes it lower. + beamOriginY: 1.25,// m — it leaves the PLAYER at chest height, not the racket head. Emitted from + // the racket (2.2 m) the apex hung in the air above him and read as detached; + // out of his chest, level with the plane it becomes, it reads as HIS shot. + beamRise: 0.14, // fraction of the length it takes to settle from the origin to that height beamW0: 0.06, // m half-width at the racket — a POINT. At 0.35 it left the racket as a 0.7 m // bar hanging in the air rather than as something emitted from the strings. beamW1: 5.0, // ...and at the far end. It is a CONE: the spread is the point of it diff --git a/index.html b/index.html index eef9520..dc95043 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/render.js b/render.js index a4958bb..8e84b34 100644 --- a/render.js +++ b/render.js @@ -3,7 +3,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS } from './config.js'; import { P, O, ball, score, crowd, G, court } from './state.js'; -import { predictLanding, previewServe , slotId, slotStock , dragContact } from './gameplay.js'; +import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js'; import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js'; // ---------------------------------------------------------------- palette ---- @@ -586,64 +586,130 @@ function drawShotBand(a, u0, flying) { // whole thing (the preview is faint, the shot is not) and `from` is the erase front: 0 draws the // whole wedge, 0.5 draws only its far half, 1 draws nothing. Drawn under the bodies, so the ones // standing in it are lit from beneath instead of being covered by it. +// The laser's plane: ONE flat translucent triangle, apex at the strings, opening out down the court +// and fading with distance. It was four nested wedges composited `lighter` — and that layering WAS +// the banding, additive light blowing out to white where the layers overlapped. Flat alpha also +// means a body standing under it is TINTED by it rather than brightened, which is what makes the +// split below read. Returns the length it drew, so the split can use the same geometry. function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) { const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; - if (from >= 1) return; const px = -uz, pz = ux; // the lane's perpendicular, on the ground + // How far it runs is solved, not tuned: walk out along the lane until the plane's own projection + // reaches the middle of the ad strip. Solving it (rather than pinning the corners afterwards) + // keeps the drawn triangle a TRUE projection of the plane, which is what lets the per-body cut + // line below agree with the edge you can see. + const target = proj(0, 0, -6).y - AD_H / 2; + let lo = 4, hi = 70; + for (let k = 0; k < 18; k++) { + const m = (lo + hi) / 2; + if (proj(ox + ux * m, TUNE.beamY, oz + uz * m).y > target) lo = m; else hi = m; + } + const L = (lo + hi) / 2; + if (from >= 1) return L; + // it leaves the racket high and settles to the cutting height within the first stretch + const yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise); const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u; - // It LEAVES THE RACKET as a POINT and opens out. Drawn flat on the lawn it read as a shape lying - // at his feet; starting wide it read as a bar hanging in mid-air. Narrow at the strings, settling - // onto the court as it goes, is what makes the near end look attached to him. - const yAt = u => oy + (0.05 - oy) * Math.min(1, u * 1.7); - const corner = (u, w) => proj(ox + ux * (u * TUNE.beamLen) + px * w, yAt(u), - 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. - const layers = [ - { k: 1.00, col: [90, 60, 255], a: 0.30 }, // outer bloom, violet - { k: 0.62, col: rgb, a: 0.50 }, // the body, in the weapon's blue - { k: 0.28, col: [130, 235, 255], a: 0.62 }, // inner, cyan - { k: 0.09, col: [255, 255, 255], a: 0.92 }, // the core - ]; - ctx.save(); - ctx.globalCompositeOperation = 'lighter'; // light ADDS — this is the whole sci-fi trick - 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 - // It must NOT fade to nothing along its length. The far corners are pinned to the ads' middle, - // but a gradient ending at alpha 0 made the beam INVISIBLE for its last stretch — so what you saw - // ended well short of where it was drawn, and no amount of moving the geometry changed it. The - // far end stays lit, so the visible edge and the pinned edge are the same line. - const g = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2, - (c3.x + c4.x) / 2, (c3.y + c4.y) / 2); - // DEPTH: brightest at the strings, thinning away down the court, because that is what a beam - // travelling away from you does. It must NOT reach zero though — a stop at alpha 0 made the beam - // invisible before its far edge, so the visible end sat short of the pinned one and moving the - // geometry appeared to change nothing at all. - g.addColorStop(0, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * alpha})`); - g.addColorStop(0.45, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * 0.58 * alpha})`); - g.addColorStop(1, `rgba(${L.col[0]},${L.col[1]},${L.col[2]},${L.a * 0.32 * alpha})`); - ctx.fillStyle = g; - ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.lineTo(c4.x, c4.y); ctx.fill(); + const corner = (u, w) => proj(ox + ux * (u * L) + px * w, yAt(u), oz + uz * (u * L) + pz * w); + const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1); + const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1); + const [r, g_, b] = RGB(WEAPONS.laser.col); + // DEPTH: strongest at the strings, thinning away down the court. Never to zero, though — a stop + // at alpha 0 made the beam invisible before its own far edge, so the visible end sat short of + // where it was drawn and moving the geometry appeared to change nothing at all. + const grad = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2, + (c3.x + c4.x) / 2, (c3.y + c4.y) / 2); + grad.addColorStop(0, `rgba(${r},${g_},${b},${0.34 * alpha})`); + grad.addColorStop(0.45, `rgba(${r},${g_},${b},${0.20 * alpha})`); + grad.addColorStop(1, `rgba(${r},${g_},${b},${0.10 * alpha})`); + ctx.fillStyle = grad; + ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y); ctx.lineTo(c4.x, c4.y); ctx.fill(); + if (from > 0) { // the erase front: a bright bar racing away down the lane + ctx.strokeStyle = `rgba(255,255,255,${0.85 * alpha})`; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke(); } - // the erase front: a bright bar racing away down the lane - if (from > 0) { - const wf = wAt(u0); - const f1 = corner(u0, -wf), f2 = corner(u0, wf); - ctx.strokeStyle = `rgba(255,255,255,${0.9 * alpha})`; ctx.lineWidth = 2; - ctx.beginPath(); ctx.moveTo(f1.x, f1.y); ctx.lineTo(f2.x, f2.y); ctx.stroke(); - } - ctx.restore(); ctx.lineWidth = 1; + return L; +} + +// The cut line: BLUEISH and faint. Near-white and bold, it read as a HUD marker stamped on the +// sprite rather than as light crossing a body — it has to whisper, not label. +const CUT_COL = 'rgba(29,153,232,0.55)'; // the laser icon's blue, for the no-filter fallback +const CUT_MAX = 8; // bodies that get a cut line in one frame (nearest first) +const CUT_MIN_PX = 14; // ...and only if it is drawn at least this tall +// canvas filters are what let the cut line follow the sprite's own silhouette; older WebKit has no +// ctx.filter at all, and there the line falls back to a narrow bar (see drawBeamCuts) +const HAS_FILTER = (() => { + try { const c = document.createElement('canvas').getContext('2d'); c.filter = 'invert(1)'; return c.filter === 'invert(1)'; } + catch { return false; } +})(); +// THE CUT. The beam is a plane at TUNE.beamY, so a body standing in it is sliced at the height that +// plane passes through it: everything below stays where it was drawn (and is tinted by the +// translucent beam laid over it), and everything above is redrawn on TOP of the beam, clipped. +// drawZombie is a pure draw — it mutates nothing — so calling it a second time is safe. +function drawBeamCuts(dirX, dirZ, ox, oz, L) { + const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; + // BOUNDED, because this is the one hot path in the renderer that costs real time: each body in the + // beam is drawn twice more, and the second pass runs through a canvas FILTER, which is slow on + // iOS. It runs every frame you are aiming, not just on the shot. Nearest first, capped, and tiny + // far-away bodies are skipped — the cut is unreadable at that size anyway. + let budget = CUT_MAX; + for (const zb of [...zombies].sort((a, b) => b.z - a.z)) { + if (budget <= 0) break; + if (!zb) continue; + const rx = zb.x - ox, rz = zb.z - oz; + const along = rx * ux + rz * uz; + if (along < 0.5 || along > L) continue; // behind the racket, or past the end + const off = Math.abs(rx * uz - rz * ux); + const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L); + if (off > halfW + 0.8) continue; + // whole device pixels: a clip edge on a fractional y crawls under nearest-neighbour scaling + const cutY = Math.round(proj(zb.x, TUNE.beamY, zb.z).y); + ctx.save(); + ctx.beginPath(); ctx.rect(0, 0, W, cutY); ctx.clip(); + drawZombie(zb); + ctx.restore(); + // ...and the CUT ITSELF, solid, straight across the body at that height, so there is no reading + // required: this is the line it comes apart on. Width is taken from the body's own drawn size + // (a sprite is about 0.6 as wide as it is tall), so a roller man gets a long one and a ball boy + // a short one, and it never runs past the silhouette into the grass. + const gp = proj(zb.x, 0, zb.z), h = zombieH(zb); + // only if the plane actually passes THROUGH it: a body the beam clears over the head of, or + // passes under the feet of, gets no line + if (cutY < proj(zb.x, h, zb.z).y || cutY > gp.y) continue; + // THE LINE IS DRAWN THROUGH THE SPRITE, not across a rectangle. A plain fillRect spanned the + // body's whole bounding width, so it also crossed the empty gaps — between the legs, under a + // held-out arm — and read as a bar lying behind him. Redrawing the sprite clipped to a one-pixel + // band, recoloured to flat blue by a canvas filter, puts the line on the BODY and nowhere else. + // THICKNESS FOLLOWS PERSPECTIVE: a share of the body's own drawn height, so a shambler at the + // far baseline gets a hairline and one at your feet gets a real band. A fixed 1 px was the same + // weight on a 3.2 m roller man in front of you as on a body by the ads. + // ...squared, because this camera's scale only spans about 1.5x across the whole court and a + // linear falloff was barely a pixel of difference end to end + const bandH = Math.max(0.3, h * gp.s * gp.s * 0.0009); + if (h * gp.s < CUT_MIN_PX) continue; // too small on screen for a line to say anything + budget--; + ctx.save(); + ctx.beginPath(); ctx.rect(0, cutY - bandH / 2, W, bandH); ctx.clip(); + if (HAS_FILTER) { + // black -> white keeps the alpha and flattens the art; the sepia/saturate/hue chain turns that + // white into one solid blue, so every lit pixel in the band is the same colour + // aimed at the blue in the LASER ICON (#1d99e8, hue ~202): black -> white flattens the art and + // keeps the alpha, sepia+saturate turns that white into a fully saturated hue, and hue-rotate + // walks it round to the icon's. Verified by sampling rendered pixels, not by eye. + // Chain found by MEASURING, not guessing: it renders (32,154,255) against the icon's + // (29,153,232). The order matters — `invert` has to leave a MID grey for `sepia` to have + // anything to tint; a chain that inverted all the way to white came out near-white, because + // sepia adds almost no chroma to white and saturating a colourless pixel does nothing. + ctx.filter = 'brightness(0) invert(0.45) sepia(1) saturate(1600%) hue-rotate(185deg)'; + ctx.globalAlpha = 0.85; // brighter: at 0.6 it disappeared into the sprite it was crossing + drawZombie(zb); + } else { // no canvas filter: fall back to a narrow bar inside the silhouette + const hw = h * 0.26 * gp.s; + ctx.fillStyle = CUT_COL; + ctx.fillRect(Math.round(gp.x - hw), cutY - bandH / 2, Math.round(hw * 2), bandH); + } + ctx.restore(); + } } // The target mark, drawn AFTER the bodies — it is a mark ON the shambler, so painting it with the @@ -1116,8 +1182,7 @@ export function render() { // are pulling, the preview is the WEDGE you are about to cut — same shape, same widening, just // not yet lit. const laserAim = G.aim && WEAPONS[slotId(G.weapon)]?.beam && !flying ? band : null; - if (laserAim) { const c = dragContact(); drawBeamWedge(laserAim.v.vx, laserAim.v.vz, c.x, c.y, c.z, 0.34, 0); } - else if (band) drawShotBand(band, bandU0, flying); + if (!laserAim && band) drawShotBand(band, bandU0, flying); // the laser previews itself below, over the court // far-side drawables in z order (far → near), then the net, then the near side const farSide = [ @@ -1145,9 +1210,15 @@ export function render() { // THE LASER's fired wedge, ERASING ALONG ITS OWN PATH. Drawn HERE — after the net and after every // body — because it is light in the air, not a decal on the grass. Drawn before them it went // UNDER the net and behind the crowd, which is exactly why it read as lying on the floor. + // The fired beam, and the AIM PREVIEW, both draw here: after the net and after every body, because + // this is light in the air. Then the bodies standing in it are re-cut on top of it. if (G.beam) { const b = G.beam, k = Math.max(0, b.t / TUNE.beamT); // 1 at the flash, 0 as it dies - drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1, 1 - k); + const L = drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 1, 1 - k); + drawBeamCuts(b.ux, b.uz, b.x, b.z, L); + } else if (laserAim) { + const L = drawBeamWedge(laserAim.v.vx, laserAim.v.vz, P.x, TUNE.beamOriginY, P.z, 0.8, 0); // faint plane, so the preview leans on more of it + drawBeamCuts(laserAim.v.vx, laserAim.v.vz, P.x, P.z, L); // "already while aiming" } if (band && !laserAim) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark diff --git a/zombies.js b/zombies.js index 283ab04..6449f13 100644 --- a/zombies.js +++ b/zombies.js @@ -6,7 +6,7 @@ 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 , whoosh } from './audio.js'; -import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal , dragContact , endBurst } from './gameplay.js'; +import { splatPoint, cheer, spentBall, resolveInterlude, showModal, endBossFight, aside, grantWeapon, weaponModal, 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 @@ -302,8 +302,9 @@ const hitR = zb => zb.variant === 'g' ? 0.75 : zb.variant === 'd' ? 0.64 : zb.va // It goes through wound()/killZombie like everything else, so it cannot no-op on a boss. export function fireBeam(dirX, dirZ) { const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; - const c = dragContact(); // it leaves the RACKET, not his feet - G.beam = { x: c.x, y: c.y, z: c.z, ux, uz, t: TUNE.beamT }; + // out of the PLAYER at chest height — same point the cone is measured from, so what you see and + // what it kills are one geometry + G.beam = { x: P.x, y: TUNE.beamOriginY, z: P.z, ux, uz, t: TUNE.beamT }; boom(0.55); G.shake = { t: 0.18, mag: 3 }; G.boomFlash = 0.08; for (let i = zombies.length - 1; i >= 0; i--) { const zb = zombies[i];