From 6ce52a6e85aeb4734f25b3cf1e62f30325e38f58 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sun, 2 Aug 2026 21:13:34 +0200 Subject: [PATCH] Two ways to spend the aim band, switchable in the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The laser look came from carrying the AIMING picture unchanged into the flight. While you pull, a bright saturated line spanning you to the target is exactly right — it is the information you are deciding on. Once fired it is spent, and a projectile drawing its own future in a continuous saturated line from itself to what it is about to hit is the visual definition of a targeting beam. No real ball shows where it is going. So a fired band now desaturates toward dust, collapses, and shrinks to a short streak. TUNE.shotTail picks which side of the ball that streak sits on: 0 a WAKE ahead of it, dissolving — your erase idea, beam cut down to a wisp 1 a TAIL behind it, nothing ahead at all — conventional projectile language The shadow follows the same slice, because "nothing ahead" has to include the ground or it still gives the future away. The target mark fades with it: a mark still burning on the shambler is the other half of the laser. Both leave the aiming band untouched. Built the collapse squared first and it was wrong — both variants were gone by a third of the flight, which is not "less like a laser", it is no streak at all, and the strobing at release is what this trail exists to prevent. Linear now. The SHORT SLICE is what kills the beam; the fade only has to make sure nothing is still burning when the ball lands. Flip it live on the phone: T panel, shotTail, type 0 or 1. Co-Authored-By: Claude Opus 5 --- config.js | 4 ++++ render.js | 70 +++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/config.js b/config.js index 28df04c..9064a42 100644 --- a/config.js +++ b/config.js @@ -168,6 +168,10 @@ export const TUNE = { // court — so every downward pull picked the same body and the angle felt inert. // 0.10 gives ±2.9 m out there: you have to actually point at a lane. pullHintAfter: 1.5, // s of doing nothing in the serve stance before the pull gesture demos itself + shotTail: 0, // HOW THE AIM BAND BEHAVES ONCE FIRED. 0 = a short WAKE ahead of the ball, + // dissolving; 1 = a short TAIL behind it and nothing ahead at all. Both keep + // the aiming band itself untouched — full arc, full colour, that part is the + // information you act on. Flip it live in the T panel; it is read per frame. aimTie: 0.12, // how much worse-aimed a NEARER body may be and still win the lane (0..1 of the // cone half-width). 0 = pure best-aligned wins; 1 = the old "nearest in the // lane always wins", which is what made the magnet grab the wrong shambler. diff --git a/render.js b/render.js index 450732d..a643c7b 100644 --- a/render.js +++ b/render.js @@ -455,37 +455,64 @@ function drawFlames(zb, hMeters) { ctx.fillRect(fx | 0, fy | 0, Math.max(1, s * 0.045), Math.max(2, s * (0.10 + 0.07 * Math.abs(Math.sin(ph * 2))))); } } -// The shot as a band through the air, drawn from u0 (0 = the whole flight, 0.6 = the ball has -// already eaten 60% of it) to the target. See the note at the call site for what each channel says. -function drawShotBand(a, u0) { +// AIMING and FLYING are two different pictures out of one arc. +// While you pull, the whole span to the target in full colour is exactly right: it is the +// information you are deciding on. Carrying that picture unchanged into the flight is what made +// the shot read as a laser — a bright, saturated, continuous line running from the projectile to +// the thing it is about to hit is the visual definition of a targeting beam, and no real ball ever +// shows its own future. So once fired the band is spent: it desaturates toward dust, collapses +// fast, and shrinks to a short streak. TUNE.shotTail picks which side of the ball that streak is on +// 0 a WAKE ahead of it, dissolving — the erase idea, with the beam cut down to a wisp +// 1 a TAIL behind it, nothing ahead at all — conventional projectile language, cannot beam +const WAKE = 0.16; // fraction of the arc drawn ahead of the ball (shotTail 0) +const TAIL = 0.15; // ...and behind it (shotTail 1) +const DUST = [206, 200, 184]; +const RGB = h => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]; +const mix = (a, b, t) => a.map((v, i) => Math.round(v + (b[i] - v) * t)); + +function drawShotBand(a, u0, flying) { const left = 1 - u0; if (left <= 0.01) return; // cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to // see where you're pointing, you just need to know it won't kill. - const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; - const N = 20; + const hex = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; + const tailMode = flying && TUNE.shotTail >= 0.5; + // the slice of arc to draw, and how hard the whole thing is already gone + const lo = !flying ? 0 : tailMode ? Math.max(0, u0 - TAIL) : u0; + const hi = !flying ? 1 : tailMode ? u0 : Math.min(1, u0 + WAKE); + if (hi - lo < 0.001) return; + // Linear, not squared. Squared made both variants basically gone by a third of the flight, which + // is not "less like a laser", it is "no streak at all" — and the strobing at release is the thing + // this whole trail exists to prevent. The SHORT SLICE is what kills the beam; the fade only has + // to make sure nothing is still burning when the ball lands. + const decay = flying ? left : 1; + const col = flying ? mix(RGB(hex), DUST, Math.min(1, u0 * 3)) : RGB(hex); // the colour is spent + const N = 14; const at = t => ({ x: a.x0 + a.v.vx * t, y: a.y0 + a.v.vy * t - 0.5 * a.g * t * t, z: a.z0 + a.v.vz * t }); - const uAt = i => u0 + left * (i / N); + const uAt = i => lo + (hi - lo) * (i / N); + // s: 0 at the far end of the slice, 1 at the ball (or at the racket, while aiming) — so the + // streak is always brightest where the action is and dies off away from it. + const sAt = i => tailMode ? i / N : 1 - i / N; // The shadow first, under everything: an arc hanging in the air is ambiguous about where it - // comes down until something pins it to the lawn. This is the whole 3D cue. + // comes down until something pins it to the lawn. This is the whole 3D cue. It follows the same + // slice — "nothing ahead" has to mean the shadow too, or the ground still gives the future away. for (let i = 0; i <= N; i++) { const w = at(a.T * uAt(i)), p = proj(w.x, 0, w.z); - ctx.fillStyle = `rgba(0,0,0,${(0.42 - 0.24 * (i / N)) * left})`; + ctx.fillStyle = `rgba(0,0,0,${(0.18 + 0.24 * sAt(i)) * decay})`; ctx.beginPath(); ctx.ellipse(p.x, p.y, Math.max(0.8, p.s * 0.07), Math.max(0.5, p.s * 0.028), 0, 0, 7); ctx.fill(); } // ...then the band. Width comes from each point's OWN projection scale, so it tapers into the - // distance like everything else on the court rather than being a flat screen-space stroke. It - // also fades along its length: solid at the racket, thinning toward the target, so it reads as - // something leaving your hand and not as a label pinned on the shambler. - ctx.strokeStyle = col; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - const head = at(a.T * u0); // the cut end: the racket while aiming, the ball once it is flying + // distance like everything else on the court rather than being a flat screen-space stroke. + ctx.strokeStyle = `rgb(${col[0]},${col[1]},${col[2]})`; + ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + const head = at(a.T * lo); let prev = proj(head.x, head.y, head.z); for (let i = 1; i <= N; i++) { const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z); - ctx.globalAlpha = (0.30 + 0.55 * a.f) * (1 - 0.55 * (i / N)) * left; - ctx.lineWidth = Math.max(1, p.s * (0.04 + a.f * 0.15)); + ctx.globalAlpha = (0.30 + 0.55 * a.f) * (0.45 + 0.55 * sAt(i)) * decay; + ctx.lineWidth = Math.max(1, p.s * (0.04 + a.f * 0.15) * (flying ? 0.75 : 1)); ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke(); prev = p; } @@ -499,13 +526,15 @@ function drawShotBand(a, u0) { // height under 1.9 m) and where the wound lands, so a ring on the grass pointed at the one part of // a shambler the shot has nothing to do with. Filled and washed out in the band's own colour // rather than a hard 2px ring, so it marks without looking like a decal stuck to the lawn. -function drawAimMark(a, u0) { +function drawAimMark(a, u0, flying) { const left = 1 - u0; if (left <= 0.01) return; const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0'; const onBody = !!a.zt; const m = proj(a.tx, a.ty, a.tz); // a.ty is where the ball ARRIVES — mark and impact are one point - ctx.globalAlpha = (onBody ? 0.42 : 0.24) * left; + // it goes with the band once fired: a mark still burning on the target is the other half of the + // laser look, and the aim it represents is already spent + ctx.globalAlpha = (onBody ? 0.42 : 0.24) * (flying ? left * left : 1); ctx.fillStyle = col; ctx.beginPath(); if (onBody) ctx.ellipse(m.x, m.y, m.s * 0.26, m.s * 0.38, 0, 0, 7); // upright, over the torso @@ -957,8 +986,9 @@ export function render() { const band = G.mode === 'splatter' && !G.interlude && (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally') ? (G.aim ? previewServe(G.aim) : G.trail) : null; - const bandU0 = band && band === G.trail ? band.t / band.T : 0; - if (band) drawShotBand(band, bandU0); + const flying = !!band && band === G.trail; // aiming draws the whole arc; a fired one is spent + const bandU0 = flying ? band.t / band.T : 0; + if (band) drawShotBand(band, bandU0, flying); // far-side drawables in z order (far → near), then the net, then the near side const farSide = [ @@ -983,7 +1013,7 @@ export function render() { if (G.mode === 'eaten' && G.eatenT < EATEN_BURST) // the dying man takes his place in the queue nearSide.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying }); nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f()); - if (band) drawAimMark(band, bandU0); // ...now that there is a body to mark + if (band) drawAimMark(band, bandU0, flying); // ...now that there is a body to mark // server idly bouncing the ball while waiting if (G.phase === 'pretoss') {