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

+10
View File
@@ -443,6 +443,16 @@ 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.
- **`TUNE.beamBand` is a live A/B between two looks**, flippable from the T panel on the phone.
**1 (default) = the BAND**: 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 like
everything else on the court, with a shadow on the lawn to pin it there. **0 = the CONE**: the solid
widening triangle. The cone IS a true projection, but it grows in world width (0.06 m -> `beamW1`),
so on screen it opens out while the court recedes — and the eye reads that as a flat shape stuck to
the screen. Oliver: "it does not look correct perspective wise".
**The KILL follows whichever look is on** (`beamBandW * 2` vs the cone's taper) in BOTH `fireBeam`
and `drawBeamCuts`, or the comparison would be a lie — measured, the cone takes 9 of 9 bodies in a
lane and the band 5 of 9.
- **THE PLANE IS OPAQUE, and that is what settled it.** While it was translucent, its opacity and - **THE PLANE IS OPAQUE, and that is what settled it.** While it was translucent, its opacity and
the over/under read were the same dial pulling in opposite directions: readable enough to show the the over/under read were the same dial pulling in opposite directions: readable enough to show the
split and it veiled the whole cone, subtle enough to look right and you could not tell above from split and it veiled the whole cone, subtle enough to look right and you could not tell above from
+7
View File
@@ -255,6 +255,13 @@ export const TUNE = {
beamW0: 0.06, // m half-width at the racket — a POINT. At 0.35 it left the racket as a 0.7 m 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. // 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 beamW1: 5.0, // ...and at the far end. It is a CONE: the spread is the point of it
// A/B, live from the T panel on the phone. 1 = draw the laser the way the BALL's aim band is
// drawn: a path down the lane whose width comes from each point's OWN projection scale, so it
// tapers into the distance and sits in the world. 0 = the solid widening cone.
// The cone is a real projection, but it GROWS in world width (0.06 m -> beamW1), so on screen it
// opens out while everything else recedes — which is what reads as a flat sticker.
beamBand: 1,
beamBandW: 0.30, // world half-width of that band — CONSTANT, which is what makes it taper
beamT: 0.42, // s the wedge stays drawn beamT: 0.42, // s the wedge stays drawn
dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is dmgBeam: 8, // what it takes off a BOSS. An ordinary body is not damaged by the beam, it is
// DELETED by it (see fireBeam); bosses "must suffer severely" but survive. // DELETED by it (see fireBeam); bosses "must suffer severely" but survive.
+1 -1
View File
@@ -179,6 +179,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=108"></script> <script type="module" src="main.js?v=109"></script>
</body> </body>
</html> </html>
+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 yAt = u => oy + (TUNE.beamY - oy) * Math.min(1, u / TUNE.beamRise);
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);
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); 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 // 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 // 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 // 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 // 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. // 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 // 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.
@@ -693,7 +732,8 @@ function drawBeamCuts(dirX, dirZ, ox, oz, L) {
const along = rx * ux + rz * uz; const along = rx * ux + rz * uz;
if (along < 0.5 || along > L) continue; // behind him, 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.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; if (off > halfW + 0.8) continue;
const gp = proj(zb.x, 0, zb.z), h = zombieH(zb); 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 // whole pixels: a clip edge on a fractional y crawls under nearest-neighbour scaling
+4 -1
View File
@@ -313,7 +313,10 @@ export function fireBeam(dirX, dirZ) {
const along = rx * ux + rz * uz; // how far down the lane it stands const along = rx * ux + rz * uz; // how far down the lane it stands
if (along < 0 || along > TUNE.beamLen) continue; // behind you, or past the end of the beam if (along < 0 || along > TUNE.beamLen) continue; // behind you, or past the end of the beam
const off = Math.abs(rx * uz - rz * ux); // lateral offset from the lane const off = Math.abs(rx * uz - rz * ux); // lateral offset from the lane
const halfW = TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / TUNE.beamLen); // the kill follows whichever LOOK is on, or the A/B would be a lie: a narrow band that deletes a
// cone-wide crowd tells you nothing about how the narrow band plays
const halfW = TUNE.beamBand ? TUNE.beamBandW * 2
: TUNE.beamW0 + (TUNE.beamW1 - TUNE.beamW0) * (along / TUNE.beamLen);
if (off > halfW + hitR(zb)) continue; if (off > halfW + hitR(zb)) continue;
// THE BEAM DELETES ORDINARY BODIES. hp does not enter into it: a plane of light that left a // THE BEAM DELETES ORDINARY BODIES. hp does not enter into it: a plane of light that left a
// roller man standing was the thing that made it feel like a worse ball rather than a weapon. // roller man standing was the thing that made it feel like a worse ball rather than a weapon.