Stop bodies blinking out, and give the cone a core and edges

The blinking was drawZombie validating its frame count against the LAST frame
only: a set with (say) z_h2 missing but z_h4 present kept N = 4, the walk cycle
stepped onto a name that had never loaded, and drawStatic silently drew nothing.
The body vanished for that frame of the cycle and came back on the next one,
forever. One cached 404 -- this project's oldest trap -- is enough to cause it.
It holds frame one now: a body that stops animating is a bug you can see, one
that flickers in and out reads as the game breaking. SPRV bumped to flush
whatever his phone has cached.

And no ctx.filter anywhere. The cut line was briefly the sprite redrawn clipped
to a band and flattened by a canvas filter, which put it exactly on the
silhouette -- and on the iPhone the line did not draw at all. Unsupported or
leaking past its restore, a rendering feature that fails silently on the target
device is not worth a pixel of polish. It is a plain bar again, brighter, and it
crosses the odd gap between the legs, which is the trade.

The cone gets the reference's look: a horizontal gradient across the width with a
near-white core down the centre line falling to blue at the edges, plus the depth
falloff along its length, plus stroked outer edges. Canvas has no 2D gradient, so
it is filled in eight slices each carrying its own across-gradient -- enough that
the seams vanish, which is what the old nested wedges got wrong.

Test mode: barrels gone (the rack already holds everything they would give you)
and the crowd raised to 16 bodies at a spawn every 0.9s, so it does not run dry
between kills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 15:58:16 +02:00
1 parent 337def8526
commit 8f16d085f0
5 files changed
+95 -76

No files matched your search

+24
View File
@@ -418,6 +418,24 @@ 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, **Weapon sounds** are a `sfx` field on the WEAPONS row: `thump` (beacon, `boom`), `swoosh` (rocket,
`whoosh`), otherwise the gun's every-other-round tick. `whoosh`), otherwise the gun's every-other-round tick.
## THE BLINKING BODIES, and why a missing frame is worse than a missing sheet (2026-08-03)
Oliver: "on iphone the zombies were blinking in and out of existence". **`drawZombie` validated its
frame count against the LAST frame only** — `if (!SPR['z_' + v + N]) N = 2` — so a set with, say,
`z_h2` missing but `z_h4` present kept `N = 4`, the walk cycle stepped onto a name that had never
loaded, and `drawStatic` silently drew NOTHING. The body vanished for that frame of the cycle and
came back on the next one, forever. A single cached 404 (this project's oldest trap) is enough.
`if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';` — hold frame one. **A body that stops
animating is a bug you can see and diagnose; one that flickers in and out reads as the game
breaking.** Every silent `SPR[x] ? x : fallback` in this file deserves the same suspicion.
**And no `ctx.filter`, anywhere.** The cut line was briefly the sprite redrawn clipped to a band and
flattened to one colour by a canvas filter, which put it exactly on the silhouette. On an iPhone the
line did not draw AT ALL. Whether that was the filter being unsupported or leaking past its
`restore()`, a rendering feature that fails silently on the target device is not worth a pixel of
polish — the line is a plain bar now and crosses the odd gap between the legs, which is the trade.
## The laser plane, and cutting bodies with it (2026-08-03) ## 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)** Settled with Oliver in discussion, then built. **The beam is a real plane at `TUNE.beamY` (1.15 m)**
@@ -425,6 +443,12 @@ that holds that height all the way out instead of sloping to the ground — that
makes the rest possible. Sloping, it lay flat on the grass across the whole far half, so there was 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. nothing for it to cut exactly where the crowd stands.
- **The fill is TWO gradients**, matching the reference Oliver sent: a horizontal one across the
width (near-white down the centre line, falling to the weapon's blue at the edges) and a depth one
along the length. Canvas has no 2D gradient, so the wedge is filled in eight slices along its
length, each with its own across-gradient at that slice's depth alpha — enough slices that the
seams vanish. The two long edges are STROKED: the reference reads as a plane because its edges are
drawn, not merely where the fill stops.
- **One flat translucent triangle**, apex at the player's CHEST (`beamOriginY` 1.25 — emitted from - **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 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 court, alpha falling with distance. It was four nested wedges composited `lighter`, and that
+3 -3
View File
@@ -234,11 +234,11 @@ export const TUNE = {
// MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack, // MEDIUM wave regardless of how far into a run you are, and every barrel weapon on the rack,
// kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than // kept topped up so nothing runs dry mid-test. It overrides the difficulty director rather than
// editing it — G.runGames keeps climbing underneath and the run is still a real run. // editing it — G.runGames keeps climbing underneath and the run is still a real run.
testZMax: 8, // bodies on court: enough to test a crowd weapon, few enough to read testZMax: 16, // bodies on court. Was 8 and the sandbox kept running dry between kills — a
// crowd weapon needs a crowd to test it against
testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp) testZSpeed: 1.15, // m/s shamble — medium, and FIXED (no per-game ramp)
testSpawnEvery: 1.6,// s between spawns testSpawnEvery: 0.9,// s between spawns: they keep coming
testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up testLevel: 20, // the roster level pickVariant pretends to be at, so every body type can turn up
testBarrelEvery: 14,// s between barrels, so the pickup itself is testable too
// ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane // ---- THE LASER's wedge: aim and release like a serve, and a plane of light widens down the lane
beamLen: 34, // m it reaches. Was 26, which stopped at the far baseline and read as the beam 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 // being CUT OFF in mid-court; the player stands at z~24, so this carries it past
+3 -9
View File
@@ -559,7 +559,7 @@ export function toggleTestMode() {
stockTestRack(); stockTestRack();
G.flash = { text: 'TEST MODE', t: 1.6 }; G.flash = { text: 'TEST MODE', t: 1.6 };
showModal({ title: 'TEST MODE', color: UI.pink, showModal({ title: 'TEST MODE', color: UI.pink,
body: ['A STEADY MEDIUM WAVE', 'EVERY BARREL WEAPON, KEPT FULL', body: ['A STEADY CROWD, MEDIUM SPEED', 'EVERY BARREL WEAPON, KEPT FULL',
'', 'TRIPLE-TAP THE SCOREBOARD AGAIN TO STOP'] }); '', 'TRIPLE-TAP THE SCOREBOARD AGAIN TO STOP'] });
return true; return true;
} }
@@ -573,16 +573,10 @@ function stockTestRack() {
else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin }); else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin });
} }
} }
let testBarrelT = 0;
export function tickTestMode(dt) { export function tickTestMode(dt) {
if (!G.testMode) return; if (!G.testMode) return;
stockTestRack(); stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and
if (G.mode !== 'splatter' || G.bossFight || G.interlude) return; } // one rolling across the sandbox was just something else to look at
if ((testBarrelT -= dt) <= 0) {
testBarrelT = TUNE.testBarrelEvery;
if (!zombies.some(z => z.variant === 'o')) spawnBarrel();
}
}
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie. // ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching. // Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
+1 -1
View File
@@ -179,6 +179,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=104"></script> <script type="module" src="main.js?v=105"></script>
</body> </body>
</html> </html>
+64 -63
View File
@@ -181,7 +181,7 @@ function drawRunOff() {
// ---------------------------------------------------------------- sprites ---- // ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load. // assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 81; // asset cache-buster — bump with main.js?v=N (see the img.src note below) const SPRV = 82; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {}; 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','p_die1','p_die2','p_die3','p_die4','p_die5', 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','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn 'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
@@ -471,6 +471,13 @@ function drawZombie(zb) {
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(walked, 2)]; const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(walked, 2)];
if (SPR[stage]) name = stage; if (SPR[stage]) name = stage;
} }
// A MISSING FRAME MUST NEVER BLINK A BODY OUT OF EXISTENCE. N above is validated against the LAST
// frame only, so a set with (say) z_h2 missing but z_h4 present keeps N = 4, the cycle steps onto a
// name that never loaded, and drawStatic silently draws NOTHING — the body vanishes for that frame
// and comes back on the next one. That is what Oliver saw on his phone, and a cached 404 (this
// project's oldest trap) is enough to cause it. Hold frame 1 instead: a body that stops animating
// is a bug you can see and diagnose; one that flickers in and out reads as the game breaking.
if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';
// Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched // Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched
// frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body // frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body
// grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule // grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule
@@ -611,18 +618,39 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u; const wAt = u => TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * u;
const corner = (u, w) => proj(ox + ux * (u * L) + px * w, yAt(u), oz + uz * (u * L) + pz * w); 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 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); const [r, g_, b] = RGB(WEAPONS.laser.col);
// DEPTH: strongest at the strings, thinning away down the court. Never to zero, though — a stop // TWO gradients, because the reference has both: a HORIZONTAL one across the width (near-white
// at alpha 0 made the beam invisible before its own far edge, so the visible end sat short of // down the centre line, falling to blue at the edges) and a DEPTH one along the length. Canvas has
// where it was drawn and moving the geometry appeared to change nothing at all. // no 2D gradient, so the wedge is filled in a handful of slices along its length, each with its
const grad = ctx.createLinearGradient((c1.x + c2.x) / 2, (c1.y + c2.y) / 2, // own across-gradient at that slice's depth alpha. Enough slices and the seams disappear; too few
(c3.x + c4.x) / 2, (c3.y + c4.y) / 2); // and it bands, which is the thing the old nested-wedge version got wrong.
grad.addColorStop(0, `rgba(${r},${g_},${b},${0.34 * alpha})`); const SEGS = 8;
grad.addColorStop(0.45, `rgba(${r},${g_},${b},${0.20 * alpha})`); for (let i = 0; i < SEGS; i++) {
grad.addColorStop(1, `rgba(${r},${g_},${b},${0.10 * alpha})`); const ua = u0 + (1 - u0) * (i / SEGS), ub = u0 + (1 - u0) * ((i + 1) / SEGS);
ctx.fillStyle = grad; const um = (ua + ub) / 2;
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 wa = wAt(ua), wb = wAt(ub), wm = wAt(um);
const a1 = corner(ua, -wa), a2 = corner(ua, wa), b2 = corner(ub, wb), b1 = corner(ub, -wb);
const el = corner(um, -wm), er = corner(um, wm); // the across axis, at this slice's width
const k = (0.34 - 0.24 * um) * alpha; // depth: strongest at the strings
const g = ctx.createLinearGradient(el.x, el.y, er.x, er.y);
g.addColorStop(0, `rgba(${r},${g_},${b},${k * 0.55})`); // edge: the weapon's blue
g.addColorStop(0.34, `rgba(${r},${g_},${b},${k})`);
g.addColorStop(0.5, `rgba(215,238,255,${k * 1.5})`); // core: near-white, the hot line
g.addColorStop(0.66, `rgba(${r},${g_},${b},${k})`);
g.addColorStop(1, `rgba(${r},${g_},${b},${k * 0.55})`);
ctx.fillStyle = g;
ctx.beginPath(); ctx.moveTo(a1.x, a1.y); ctx.lineTo(a2.x, a2.y); ctx.lineTo(b2.x, b2.y); ctx.lineTo(b1.x, b1.y);
ctx.closePath(); ctx.fill();
}
// ...and the OUTER LINES. The reference reads as a plane because its edges are drawn, not just
// implied by where the fill stops.
const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1);
ctx.strokeStyle = `rgba(190,225,255,${0.5 * alpha})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(c2.x, c2.y); ctx.lineTo(c3.x, c3.y);
ctx.moveTo(c1.x, c1.y); ctx.lineTo(c4.x, c4.y);
ctx.stroke();
if (from > 0) { // the erase front: a bright bar racing away down the lane 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.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(); ctx.beginPath(); ctx.moveTo(c1.x, c1.y); ctx.lineTo(c2.x, c2.y); ctx.stroke();
@@ -633,83 +661,56 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, alpha, from) {
// The cut line: BLUEISH and faint. Near-white and bold, it read as a HUD marker stamped on the // 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. // 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_COL = 'rgba(58,175,255,0.85)'; // the laser icon's blue (#1d99e8), lifted so a flat bar reads
const CUT_MAX = 8; // bodies that get a cut line in one frame (nearest first) 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 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 // NO ctx.filter ANYWHERE. The cut line used to be the sprite redrawn clipped to a band and flattened
// ctx.filter at all, and there the line falls back to a narrow bar (see drawBeamCuts) // to one colour by a canvas filter, which put it exactly on the silhouette — and on an iPhone the
const HAS_FILTER = (() => { // line did not draw AT ALL and bodies blinked in and out of existence, which is what a filter
try { const c = document.createElement('canvas').getContext('2d'); c.filter = 'invert(1)'; return c.filter === 'invert(1)'; } // leaking past its restore() looks like. Not worth it at any price. Plain bars from here.
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 // 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 // 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. // 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. // drawZombie is a pure draw — it mutates nothing — so calling it a second time is safe.
function drawBeamCuts(dirX, dirZ, ox, oz, L) { function drawBeamCuts(dirX, dirZ, ox, oz, L) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d; 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 // BOUNDED: each body in the beam is drawn once MORE (the part above the plane), every frame you
// beam is drawn twice more, and the second pass runs through a canvas FILTER, which is slow on // are aiming and not only on the shot. Nearest first, capped, and bodies too small on screen for a
// iOS. It runs every frame you are aiming, not just on the shot. Nearest first, capped, and tiny // cut to say anything are skipped.
// far-away bodies are skipped — the cut is unreadable at that size anyway.
let budget = CUT_MAX; let budget = CUT_MAX;
for (const zb of [...zombies].sort((a, b) => b.z - a.z)) { for (const zb of [...zombies].sort((a, b) => b.z - a.z)) {
if (budget <= 0) break; if (budget <= 0) break;
if (!zb) continue; if (!zb) continue;
const rx = zb.x - ox, rz = zb.z - oz; const rx = zb.x - ox, rz = zb.z - oz;
const along = rx * ux + rz * uz; const along = rx * ux + rz * uz;
if (along < 0.5 || along > L) continue; // behind the racket, or past the end if (along < 0.5 || along > L) continue; // behind him, or past the end
const off = Math.abs(rx * uz - rz * ux); const off = Math.abs(rx * uz - rz * ux);
const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L); const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L);
if (off > halfW + 0.8) continue; if (off > halfW + 0.8) continue;
// whole device pixels: a clip edge on a fractional y crawls under nearest-neighbour scaling const gp = proj(zb.x, 0, zb.z), h = zombieH(zb);
// whole 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); const cutY = Math.round(proj(zb.x, TUNE.beamY, zb.z).y);
// a NaN anywhere here would make the clip path invalid, and an invalid clip is the sort of thing
// that makes a body vanish for a frame — skip rather than draw something undefined
if (!Number.isFinite(cutY) || !Number.isFinite(h) || !Number.isFinite(gp.s)) continue;
if (h * gp.s < CUT_MIN_PX) continue;
budget--;
// the part ABOVE the plane is redrawn on top of it; everything below stays where it was and is
// tinted by the translucent beam already laid over it
ctx.save(); ctx.save();
ctx.beginPath(); ctx.rect(0, 0, W, cutY); ctx.clip(); ctx.beginPath(); ctx.rect(0, 0, W, cutY); ctx.clip();
drawZombie(zb); drawZombie(zb);
ctx.restore(); ctx.restore();
// ...and the CUT ITSELF, solid, straight across the body at that height, so there is no reading // ...and THE LINE, only where the plane actually passes through the body (one that the beam
// required: this is the line it comes apart on. Width is taken from the body's own drawn size // clears over the head of, or passes under the feet of, gets none).
// (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; 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 // Thickness follows perspective — a share of the body's own drawn height, squared, because this
// body's whole bounding width, so it also crossed the empty gaps — between the legs, under a // camera's scale only spans about 1.5x across the court and a linear falloff was invisible.
// held-out arm — and read as a bar lying behind him. Redrawing the sprite clipped to a one-pixel const bandH = Math.max(0.4, h * gp.s * gp.s * 0.0009);
// band, recoloured to flat blue by a canvas filter, puts the line on the BODY and nowhere else. const hw = h * 0.22 * gp.s; // narrow, so it stays inside the silhouette rather than overshooting
// 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.fillStyle = CUT_COL;
ctx.fillRect(Math.round(gp.x - hw), cutY - bandH / 2, Math.round(hw * 2), bandH); 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 // The target mark, drawn AFTER the bodies — it is a mark ON the shambler, so painting it with the