Two ways to spend the aim band, switchable in the panel
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
7284ca08f8
commit
6ce52a6e85
2 files changed
+54
-20
No files matched your search
@@ -168,6 +168,10 @@ export const TUNE = {
|
|||||||
// court — so every downward pull picked the same body and the angle felt inert.
|
// 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.
|
// 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
|
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
|
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
|
// 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.
|
// lane always wins", which is what made the magnet grab the wrong shambler.
|
||||||
|
|||||||
@@ -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)))));
|
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
|
// AIMING and FLYING are two different pictures out of one arc.
|
||||||
// already eaten 60% of it) to the target. See the note at the call site for what each channel says.
|
// While you pull, the whole span to the target in full colour is exactly right: it is the
|
||||||
function drawShotBand(a, u0) {
|
// 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;
|
const left = 1 - u0;
|
||||||
if (left <= 0.01) return;
|
if (left <= 0.01) return;
|
||||||
// cool blue-white = this only staggers. It stays READABLE on the dark lawn: you still need to
|
// 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.
|
// 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 hex = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
||||||
const N = 20;
|
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,
|
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 });
|
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
|
// 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++) {
|
for (let i = 0; i <= N; i++) {
|
||||||
const w = at(a.T * uAt(i)), p = proj(w.x, 0, w.z);
|
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.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();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
// ...then the band. Width comes from each point's OWN projection scale, so it tapers into the
|
// ...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
|
// distance like everything else on the court rather than being a flat screen-space stroke.
|
||||||
// also fades along its length: solid at the racket, thinning toward the target, so it reads as
|
ctx.strokeStyle = `rgb(${col[0]},${col[1]},${col[2]})`;
|
||||||
// something leaving your hand and not as a label pinned on the shambler.
|
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
||||||
ctx.strokeStyle = col; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
|
const head = at(a.T * lo);
|
||||||
const head = at(a.T * u0); // the cut end: the racket while aiming, the ball once it is flying
|
|
||||||
let prev = proj(head.x, head.y, head.z);
|
let prev = proj(head.x, head.y, head.z);
|
||||||
for (let i = 1; i <= N; i++) {
|
for (let i = 1; i <= N; i++) {
|
||||||
const w = at(a.T * uAt(i)), p = proj(w.x, w.y, w.z);
|
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.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));
|
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();
|
ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
||||||
prev = p;
|
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
|
// 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
|
// 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.
|
// 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;
|
const left = 1 - u0;
|
||||||
if (left <= 0.01) return;
|
if (left <= 0.01) return;
|
||||||
const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
const col = a.q >= TUNE.zSplatQ ? (a.zt ? UI.hot : UI.warm) : '#9db4d0';
|
||||||
const onBody = !!a.zt;
|
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
|
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.fillStyle = col;
|
||||||
ctx.beginPath();
|
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
|
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
|
const band = G.mode === 'splatter' && !G.interlude
|
||||||
&& (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')
|
&& (G.phase === 'pretoss' || G.phase === 'toss' || G.phase === 'rally')
|
||||||
? (G.aim ? previewServe(G.aim) : G.trail) : null;
|
? (G.aim ? previewServe(G.aim) : G.trail) : null;
|
||||||
const bandU0 = band && band === G.trail ? band.t / band.T : 0;
|
const flying = !!band && band === G.trail; // aiming draws the whole arc; a fired one is spent
|
||||||
if (band) drawShotBand(band, bandU0);
|
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
|
// far-side drawables in z order (far → near), then the net, then the near side
|
||||||
const farSide = [
|
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
|
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.push({ z: (G.eatenAt || P).z - 0.12, f: drawDying });
|
||||||
nearSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
|
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
|
// server idly bouncing the ball while waiting
|
||||||
if (G.phase === 'pretoss') {
|
if (G.phase === 'pretoss') {
|
||||||
|
|||||||
Reference in new issue
Block a user