Shape the cone by hand, and give the playground its knobs back

Oliver's tuned numbers become the defaults: beamY 0.95, beamLen 23, beamW0 0.08,
beamW1 3.1, beamOriginLift 0.44, beamBorder 0.18.

Three knobs earned their keep on the way there.

beamLen is the DRAWN length now, not only the kill range. The drawn length was
solved every frame so the far end landed on the middle of the ad strip, which
made this a control that visibly did nothing. One meaning beats one clever
behaviour -- and it pairs with beamY, since a lower plane meets the ads sooner.

beamOriginLift replaces beamOriginY: the apex is a lift ABOVE the plane rather
than an absolute height, so lowering beamY brings the near end down with it
instead of leaving it hanging at chest height with the beam sloping away.

beamBorder exposes the edge width, which was hardcoded.

And the playground is back, deliberately NOT paused. The first version froze the
world so the picture would hold still, and then nothing redrew, so every value
changed did nothing -- a still frame you cannot change is worth nothing. The
bodies are held still individually and spawning is off instead.

Also: a boolean living in TUNE has to be tested with >= 0.5. The panel steps
every key by 0.02, so one nudge set beamBand to 0.02 -- still truthy -- and the
renderer switched variants under him mid-tune.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 17:58:51 +02:00
1 parent ac1d5fbccd
commit 99774ab4be
6 files changed
+125 -62

No files matched your search

+38 -47
View File
@@ -601,57 +601,48 @@ function drawShotBand(a, u0, flying) {
function drawBeamWedge(dirX, dirZ, ox, oy, oz, from) {
const d = Math.hypot(dirX, dirZ) || 1, ux = dirX / d, uz = dirZ / d;
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;
// The DRAWN length is TUNE.beamLen, straight. It used to be solved here each frame so the far end
// landed on the middle of the ad strip — which made beamLen a knob that visibly did nothing, since
// it only governed the kill. One meaning is worth more than one clever behaviour.
const L = TUNE.beamLen;
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;
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; }
// >= 0.5, not just truthy: the T panel steps numbers by 0.02, so one nudge on this flag set it to
// 0.02 — still truthy, and the renderer silently switched under Oliver mid-tune. A flag living in
// a table of numbers has to be robust to being treated as a number.
if (TUNE.beamBand >= 0.5) { 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
// no 2D gradient, so the wedge is filled in a handful of slices along its length, each with its
// own across-gradient at that slice's depth alpha. Enough slices and the seams disappear; too few
// and it bands, which is the thing the old nested-wedge version got wrong.
const SEGS = 8;
for (let i = 0; i < SEGS; i++) {
const ua = u0 + (1 - u0) * (i / SEGS), ub = u0 + (1 - u0) * ((i + 1) / SEGS);
const um = (ua + ub) / 2;
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
// FULLY OPAQUE. Depth is carried by BRIGHTNESS, not by alpha — the plane is solid colour, dark
// blue at the edges and near-white down the core, dimming as it runs away from you. Nothing under
// it shows through, so "above the plane" and "below the plane" stop being a matter of degree:
// the part above is simply there and the part below is simply gone behind it.
const f = 1 - 0.5 * um; // depth: full at the strings, half at the ads
const mix = (c, m) => `rgb(${Math.round(c[0] * m)},${Math.round(c[1] * m)},${Math.round(c[2] * m)})`;
const g = ctx.createLinearGradient(el.x, el.y, er.x, er.y);
g.addColorStop(0, mix(BEAM_EDGE, f * 0.8)); // edge: deep blue
g.addColorStop(0.30, mix(BEAM_MID, f));
g.addColorStop(0.5, mix(BEAM_CORE, f)); // core: the hot line down the middle
g.addColorStop(0.70, mix(BEAM_MID, f));
g.addColorStop(1, mix(BEAM_EDGE, f * 0.8));
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();
// THE SHADOW ON THE LAWN, borrowed from the ball's aim band and from the band variant — this is
// most of why those read as lying in the world. A bright shape hanging in the air is ambiguous
// about where it actually is until something pins it to the ground.
for (let i = 0; i <= 14; i++) {
const u = u0 + (1 - u0) * (i / 14), w = wAt(u);
const p = proj(ox + ux * (u * L), 0, oz + uz * (u * L));
ctx.fillStyle = `rgba(0,0,0,${0.16 * (1 - u * 0.55)})`;
ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * w * 0.9), Math.max(0.4, p.s * w * 0.3), 0, 0, 7);
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.
// ONE gradient, along the LENGTH only. There was a second one across the width (near-white core
// falling to blue at the edges) and it is gone: across a shape this narrow it read as a highlight
// painted down the middle rather than as depth. The plane is a flat solid colour now, dimming as
// it runs away from you — that is the whole shading.
const c1 = corner(u0, -w0), c2 = corner(u0, w0), c3 = corner(1, w1), c4 = corner(1, -w1);
ctx.strokeStyle = 'rgb(210,238,255)'; // the outer lines, solid like the rest of it
ctx.lineWidth = 1;
const mix = (c, m) => `rgb(${Math.round(c[0] * m)},${Math.round(c[1] * m)},${Math.round(c[2] * m)})`;
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, mix(BEAM_MID, 1));
grad.addColorStop(1, mix(BEAM_MID, 0.5));
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.closePath(); ctx.fill();
// ...and the OUTER LINES. It reads as a plane because its edges are drawn, not merely implied by
// where the fill stops.
ctx.strokeStyle = 'rgb(196,230,255)'; // the outer lines — width is a knob (beamBorder)
ctx.lineWidth = TUNE.beamBorder;
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);
@@ -667,8 +658,8 @@ function drawBeamWedge(dirX, dirZ, ox, oy, oz, from) {
// 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(58,175,255,0.85)'; // the laser icon's blue (#1d99e8), lifted so a flat bar reads
// The plane's three colours, edge -> core. Opaque: see the gradient in drawBeamWedge.
const BEAM_EDGE = [26, 62, 178];
// The plane's colour. Opaque, one flat blue, dimmed with distance — see drawBeamWedge. BEAM_CORE is
// still used by the band variant's hot centre line (TUNE.beamBand).
const BEAM_MID = [63, 116, 255];
const BEAM_CORE = [206, 238, 255];
const CUT_MAX = 8; // bodies that get a cut line in one frame (nearest first)
@@ -732,7 +723,7 @@ 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.beamBand ? TUNE.beamBandW * 2 // must match fireBeam's test, or the cut
const halfW = TUNE.beamBand >= 0.5 ? 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);
@@ -1266,7 +1257,7 @@ export function render() {
const L = drawBeamWedge(b.ux, b.uz, b.x, b.y, b.z, 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); // the preview is the same solid plane
const L = drawBeamWedge(laserAim.v.vx, laserAim.v.vz, P.x, TUNE.beamY + TUNE.beamOriginLift, P.z, 0); // the preview is the same solid plane
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