diff --git a/HANDOFF.md b/HANDOFF.md index b979613..910f5de 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 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 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 diff --git a/config.js b/config.js index 2bae747..886d8a9 100644 --- a/config.js +++ b/config.js @@ -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 // 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 + // 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 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. diff --git a/index.html b/index.html index 81b1317..1c19703 100644 --- a/index.html +++ b/index.html @@ -179,6 +179,6 @@ - + diff --git a/render.js b/render.js index 1972667..891d747 100644 --- a/render.js +++ b/render.js @@ -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 diff --git a/zombies.js b/zombies.js index 6449f13..44b77fe 100644 --- a/zombies.js +++ b/zombies.js @@ -313,7 +313,10 @@ export function fireBeam(dirX, dirZ) { 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 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; // 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.