Draw the laser like the ball's aim band, and let the two be compared

TUNE.beamBand flips between the two looks live from the T panel. 1, the new
default, draws the laser the way the BALL's aim trajectory is drawn: a path down
the lane whose width comes from each point's own projection scale, so it narrows
into the distance like everything else on the court, with a shadow on the lawn to
pin it there. 0 is the solid widening cone.

The cone was a true projection, which is why it was not obviously wrong to me --
but it GROWS in world width, so on screen it opens out while the court recedes,
and the eye reads that as a shape stuck to the screen rather than a plane lying
in the world.

The kill follows whichever look is on, in both fireBeam and drawBeamCuts.
Otherwise the comparison would be a lie: a narrow band that deletes a cone-wide
crowd tells you nothing about how the narrow band plays. Measured down one lane,
the cone takes 9 of 9 and the band 5 of 9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 17:26:18 +02:00
1 parent 601aa934ad
commit ac1d5fbccd
5 files changed
+63 -3

No files matched your search

+41 -1
View File
@@ -617,6 +617,7 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, from) {
const yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise);
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);
if (TUNE.beamBand) { drawBeamBand(ux, uz, ox, oy, oz, L, from); return L; }
const u0 = Math.max(0, from), w0 = wAt(u0), w1 = wAt(1);
// TWO gradients, because the reference has both: a HORIZONTAL one across the width (near-white
// down the centre line, falling to blue at the edges) and a DEPTH one along the length. Canvas has
@@ -676,6 +677,44 @@ const CUT_MIN_PX = 14; // ...and only if it is drawn at least this tall
// to one colour by a canvas filter, which put it exactly on the silhouette — and on an iPhone the
// line did not draw AT ALL and bodies blinked in and out of existence, which is what a filter
// leaking past its restore() looks like. Not worth it at any price. Plain bars from here.
// THE BAND VARIANT (TUNE.beamBand = 1). Drawn the way the BALL's aim trajectory is drawn: a path
// down the lane whose width comes from each point's OWN projection scale, so it narrows into the
// distance exactly like everything else on the court. That is the difference — the cone is a real
// projection too, but it GROWS in world width, so on screen it opens out while the court recedes,
// and the eye reads that as a flat shape stuck to the screen rather than a plane lying in the world.
function drawBeamBand(ux, uz, ox, oy, oz, L, from) {
const N = 20;
const yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise);
const at = u => ({ x: ox + ux * (u * L), y: yAt(u), z: oz + uz * (u * L) });
const u0 = Math.max(0, from);
if (u0 >= 1) return;
const uAt = i => u0 + (1 - u0) * (i / N);
// the shadow on the lawn first, same as the ball's band: an arc hanging in the air is ambiguous
// about where it lies until something pins it to the ground
for (let i = 0; i <= N; i++) {
const w = at(uAt(i)), p = proj(w.x, 0, w.z);
ctx.fillStyle = `rgba(0,0,0,${0.20 * (1 - uAt(i) * 0.6)})`;
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * TUNE.beamBandW * 0.9),
Math.max(0.4, p.s * TUNE.beamBandW * 0.3), 0, 0, 7); ctx.fill();
}
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
// two passes: a wide soft body and a hot core down the middle, both scaled per point
for (const [wf, col] of [[2.0, BEAM_MID], [0.7, BEAM_CORE]]) {
let prev = null;
for (let i = 0; i <= N; i++) {
const u = uAt(i), w = at(u), p = proj(w.x, w.y, w.z);
if (prev) {
const f = 1 - 0.45 * u; // depth by brightness, as the cone does
ctx.strokeStyle = `rgb(${Math.round(col[0] * f)},${Math.round(col[1] * f)},${Math.round(col[2] * f)})`;
ctx.lineWidth = Math.max(1, p.s * TUNE.beamBandW * wf);
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
}
prev = p;
}
}
ctx.lineWidth = 1;
}
// 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.
@@ -693,7 +732,8 @@ function drawBeamCuts(dirX, dirZ, ox, oz, L) {
const along = rx * ux + rz * uz;
if (along < 0.5 || along > L) continue; // behind him, or past the end
const off = Math.abs(rx * uz - rz * ux);
const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L);
const halfW = TUNE.beamBand ? TUNE.beamBandW * 2 // must match fireBeam's test, or the cut
: TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / L); // marks bodies the shot will miss
if (off > halfW + 0.8) continue;
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