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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 15:48:08 +02:00
1 parent da9cca2f23
commit 337def8526
5 files changed
+176 -60

No files matched your search

+127 -56
View File
@@ -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