The aim is a mark on the grass, not a line drawn over the game

The live flick readout was a ring on a ray from the player's feet — a HUD
element laid across the court, and read as one: "pretty bare bones... the line
must go away, it is distracting". It is a soft cloud on the ground now, in the
register the dust and the blood already use: it sits where the ball is being
asked to land, grows with the length of the swipe, and warms from pale lime
through gold to red as that ask approaches the width no timing can keep in. The
threshold stopped being a flip, so too hard is something you watch coming.

Three things it took to make it read, kept in the comments so the next pass
does not re-learn them: it is sized in METRES off the projection, or it is an
eight-pixel smudge at the far corner where it actually lives; it is a stain and
not an additive glow, which turned the red end yellow over green grass; and the
ramp starts pale rather than green, because green on green showed nothing at
all across the whole safe half of the range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-18 07:53:09 +02:00
1 parent d5a069a90c
commit 1b16b422df
3 files changed
+49 -20

No files matched your search

+17 -4
View File
@@ -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
+1 -1
View File
@@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=282"></script>
<script type="module" src="src/main.js?v=287"></script>
</body>
</html>
+31 -15
View File
@@ -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)