diff --git a/PROJECT.md b/PROJECT.md index cc3bbd0..3b5a965 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -524,10 +524,23 @@ depth only and holding your own artillery lane is the point. **THE AIM IS DRAWN WHILE THE THUMB IS STILL MOVING.** Oliver asked to *"visualize the strength of my flick at flick-time"*, and he was asking for the half the placement ring cannot give: the ring is a RECEIPT, it arrives with the ball already gone. `G.swipe` (set by input.js during the drag, cleared -on release) draws the same target ring live, plus a RAY from the player's feet — the ring alone sits -deep in perspective and 300 ms is not long enough to go looking for it. Cream while the ask is safe, -`UI.hot` once the sway crosses that derived threshold: the colour is the answer to "was I too hard". -Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same +on release) marks where the ball is being ASKED to land. + +**It is a cloud, not a diagram.** The first cut was a ring on a ray from the player's feet and it +was rejected on sight (*"pretty bare bones... the line from the player to the indicator must go +away, it is distracting"*): a line drawn across the court is a HUD element laid over the game. +What is there now is a soft stain on the ground in the same procedural register as the dust and the +blood — it GROWS with the length of the swipe and it WARMS as that ask approaches the width no +timing can keep in. Three things were learned making it read, all of them the hard way: +* **metres, not pixels** — sized off `p.s` so perspective shrinks it with distance. A pixel radius + looked fine at the baseline and was an 8-px smudge at the far corner, which is where it lives. +* **a stain, not a glow** — `globalCompositeOperation = 'lighter'` turned the red end YELLOW over + green grass, and the hue is the entire message. +* **the ramp is pale lime -> gold -> red, not green -> red** — green on green is invisible, so the + obvious ramp showed nothing across the whole safe half of its range. The cool end carries its + contrast in LIGHTNESS, which is what makes it read on grass, clay and slab alike. +The threshold is not a flip: the colour is already gold before you reach it, so "too hard" is +something you watch coming. Only inside the window where a release would still BE a flick (`FLICK_MS`/`TUNE.flickMin`, the same gate `endTouch` uses) — past that the gesture is a run and a target hanging off it would be a lie. Rally modes only (`rallyFeel`); gore's flick free-aims at a body, not at a lane. The placement ring turns `UI.bad` when the shot really is out, so the two rings together ARE the lesson: what you asked diff --git a/index.html b/index.html index dd97f0f..0d22d60 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/render.js b/src/render.js index 9e09c9d..092b8eb 100644 --- a/src/render.js +++ b/src/render.js @@ -16,6 +16,14 @@ const hexLerp = (a, b, t) => { const [ar, ag, ab] = c(a), [br, bg, bb] = c(b); return `rgb(${Math.round(ar + (br - ar) * t)},${Math.round(ag + (bg - ag) * t)},${Math.round(ab + (bb - ab) * t)})`; }; +// The live aim cloud's ramp, walked in TWO steps: pale lime -> gold -> red. A straight green-to-red +// lerp was the obvious version and it failed on the only court that matters — green on green is +// invisible, so the whole safe half of the range showed nothing. The cool end carries its contrast +// in LIGHTNESS instead of hue, which is what makes it read on grass, clay and slab alike, and gold +// in the middle keeps every step of the ramp distinct from the two beside it. +const AIM_COOL = '#d8eaae', AIM_MID = '#e0cc72', AIM_HOT = '#d64a3a'; +const aimHeat = t => t < 0.5 ? hexLerp(AIM_COOL, AIM_MID, t * 2) : hexLerp(AIM_MID, AIM_HOT, (t - 0.5) * 2); + // how far into the rot we are, 0 (tennis) .. 1 (splatter) — duels lerp it back home function paletteT() { return G.mode === 'tennis' ? 0 : G.mode === 'splatter' ? 1 - G.duelLerp : Math.min(1, G.transT / 6); @@ -2290,21 +2298,29 @@ export function render() { const sway = swayOf(G.swipe); const x = sway * TUNE.flickWidth; const z = G.swipe.fy < -0.38 ? SHOT_DEPTH.deep : G.swipe.fy > 0.38 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; - // ...and it goes HOT exactly where the shot stops being safe. Below this much sway the worst - // mistiming in the game cannot push the scatter past the sideline; above it, it can — so the - // colour is the honest answer to "was I too hard?", and it is DERIVED from the same three - // numbers doHit spends, never a hand-picked threshold that would rot the moment one moved. - const safe = (COURT.W / 2 - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach); - const col = Math.abs(sway) > safe ? UI.hot : UI.cream; - ctx.globalAlpha = 0.35 + 0.45 * Math.abs(sway); // the size of the ask is the brightness - // the ray is what makes the ring findable: the target sits deep in perspective and is small, - // and 300 ms is not long enough to go looking for it. It leaves from your own feet, so the - // gesture, the man and the corner are one line — and its length IS the strength you swiped. - const a = proj(P.x, 0, P.z), b = proj(x, 0, z); - ctx.strokeStyle = col; ctx.lineWidth = 2; - ctx.beginPath(); ctx.moveTo(Math.round(a.x), Math.round(a.y)); ctx.lineTo(Math.round(b.x), Math.round(b.y)); ctx.stroke(); - drawRing(x, z, col, 0.9); - ctx.globalAlpha = 1; + // A CLOUD, NOT A DIAGRAM. The first cut was a ring on a ray from the player's feet, and a line + // drawn across the court is a HUD element laid over the game — Oliver: "pretty bare bones... + // the line must go away, it is distracting". This is a soft mark on the grass instead, in the + // same procedural register as the dust and the blood: it sits where the ball is being asked to + // land, it GROWS with the length of the swipe, and it warms from green to red as that ask + // approaches the width no timing can keep in. The threshold is not a flip — the colour is + // already amber before you reach it, so "too hard" is something you watch coming. + const a = Math.abs(sway), p = proj(x, 0, z); + if (p.s > 0) { + const safe = (COURT.W / 2 - TUNE.aimError) / (TUNE.flickWidth * TUNE.flickReach); + const col = aimHeat(Math.min(1, a / safe)); + const rx = p.s * (0.55 + 1.35 * a), ry = rx * 0.4; // metres of ground, so perspective shrinks it with distance + ctx.fillStyle = col; + // NOT additive: 'lighter' turned the red end yellow over green grass, and the hue IS the + // message here. A soft stain keeps green green and red red on every court in the table. + // stacked rather than a gradient: three soft passes build a chunky core the way the dust and + // the piles do, and a smooth radial ramp would be the one un-pixelated thing on the court + for (let i = 3; i >= 1; i--) { + ctx.globalAlpha = 0.13 + 0.10 * a; + ctx.beginPath(); ctx.ellipse(Math.round(p.x), Math.round(p.y), rx * i / 3, ry * i / 3, 0, 0, 7); ctx.fill(); + } + ctx.globalAlpha = 1; + } } // landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down — // the ring that lingered where the ball had already landed confused testers and is removed)