From 6f9dea4ffc74671fc9592db76924f85dc0df3516 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Thu, 20 Aug 2026 13:26:30 +0200 Subject: [PATCH] The flick fires the moment it is a flick: the swing launches at the crossing, and the aim rides the thumb to contact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Oliver's service-line report: flicking the incoming ball LEFT was near impossible mid-court at the service line while RIGHT worked, easing toward the baseline. The lane math was symmetric to the centimetre — the defect was latency. The swing waited for the RELEASE, so the whole gesture ran as dead time on top of the windup: ~120 ms of lateness drops contact quality 1.0 -> 0.08, where flickReach halves the width and aimError drowns the rest. A left swipe is a right thumb's slowest direction, the service line leaves ~700-870 ms of budget (measured), and any swipe over FLICK_MS was silently dead. Left + service line was the slowest gesture meeting the shortest budget. Now, in rally modes: crossing flickMin fires the swing (once per gesture, rally phase only) and the vector stays LIVE — trySwing reads G.swipe at contact, with the released flickAim carrying the aim only when the ball arrives after the finger lifts. The FLICK_MS deadline goes with it where the crossing can fire; it survives where it cannot (gore free-aim, the serve strike). Serve gesture, duel, gore and keys untouched. Co-Authored-By: Claude Fable 5 --- PROJECT.md | 27 ++++++++++++++++++++++++--- index.html | 2 +- src/gameplay.js | 5 ++++- src/input.js | 35 +++++++++++++++++++++++++---------- test.js | 28 ++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 180f210..872c290 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -552,12 +552,33 @@ The threshold is not a flip: the colour is already gold before you reach it, so something you watch coming. **It is OFF as of its first playtest** (*"hide the indicator for now"*) — `TUNE.aimCloud` gates the whole block and the tuning panel can flip it back live. Kept rather than deleted: it is the only thing that shows the gesture's strength before the ball leaves, and the -lesson it teaches has no other teacher. 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 +lesson it teaches has no other teacher. Shown from `TUNE.flickMin` for as long as the drag lives — +since the crossing-fired flick (below) the vector stays live until contact, so there is no release +deadline for it to lie past. 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 for, what your timing gave you, and the difference between them. +**...and the flick fires the moment it IS a flick** (2026-08-20, Oliver: *"trying to move the +approaching ball with a flick to the left is almost impossible ... standing closer to the grundlinie +and more right from the center eases this restriction"*). The aim math was symmetric to the +centimetre — the defect was LATENCY. The swing waited for the RELEASE, so the whole gesture ran as +dead time on top of the 60 ms windup, and ~120 ms of lateness drops contact quality 1.0 -> 0.08, +where `flickReach` halves the width and `aimError` drowns what is left. A LEFT swipe is the slowest +direction a right thumb has (~250-350 ms vs ~150 for right), the service line leaves only ~700-870 ms +of budget against even the slowest rung (~1.1-1.3 s at the baseline — measured), and any swipe over +`FLICK_MS` (300 ms) was silently DEAD: no swing, no feedback, ball passes. Left + service line was +exactly the slowest gesture meeting the shortest budget. Two changes, rally modes only +(`rallyFeel`), input.js + one line in `trySwing`: **the swing fires when the drag crosses +`flickMin`** (in `G.phase === 'rally'`, once per gesture) rather than at release, and **the aim +stays LIVE** — `trySwing` reads `G.swipe` at contact, so the ball takes the vector under the thumb +NOW; the released `flickAim` only carries it when the ball arrives after the finger lifted. The +FLICK_MS deadline is gone with it where the crossing can fire — it survives only where it cannot +(gore's free-aim flick, the serve-phase strike), where a late release must stay a rescue drag. The +serve gesture, the duel and gore are untouched; keys unchanged. Verified with real touch events: +held-swipe contact aims exactly `swayOf`, a 360 ms swipe (previously dead) swings and aims, and a +release-before-arrival still aims through `takeFlickAim`. + **The umpire and the boy.** `aside()` refused during any interlude and the match runs on a permanent one, so he was silently muted for the entire mode — the one mode that is literally a tennis match he is sitting over (`G.interlude && !G.match` now, the same shape as `sendBoy`). Six dry lines: two on diff --git a/index.html b/index.html index fc3cbb9..06d6b67 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/gameplay.js b/src/gameplay.js index d762824..47fea10 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1283,7 +1283,10 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { let q = Math.max(0.05, 1 - off); if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1; ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite - const flick = isAI ? null : takeFlickAim(); + // the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the + // finger is usually still down at contact, and the vector under it NOW is the aim the player + // means — the released flickAim only carries it when the ball arrives after the finger lifted. + const flick = isAI ? null : (G.swipe || takeFlickAim()); // SMART AIM (G.diff.smart, classic difficulty): with this probability the CPU plays the OPEN // COURT — the corner away from where you stand — instead of rolling a random lane. Random aim // sends a third of everything straight back to your racket; hunting the open corner is what diff --git a/src/input.js b/src/input.js index 448358c..09316a8 100644 --- a/src/input.js +++ b/src/input.js @@ -191,14 +191,23 @@ cv.addEventListener('touchmove', e => { const legs = !rallyFeel(); touchAxis.x = legs && Math.abs(dx) > DEAD ? Math.sign(dx) : 0; touchAxis.z = legs && Math.abs(dy) > DEAD ? Math.sign(dy) : 0; - // THE FLICK, SHOWN WHILE IT IS STILL A FLICK. The placement ring is a receipt — it arrives with - // the ball already gone. This is the same aim BEFORE the release, which is the half the player - // could not see: how much width the length of the swipe is buying, and whether that is already - // past the line. Only inside the window where a release would still BE a swing (the same - // FLICK_MS/flickMin gate endTouch uses) — past that the gesture is a run and a target hanging - // off it would be a lie. Rally modes only: gore's flick free-aims at a body, not at a lane. - G.swipe = rallyFeel() && dist > TUNE.flickMin && performance.now() - stick.t0 < FLICK_MS - ? { fx: dx / dist, fy: dy / dist, mag: dist } : null; + // THE FLICK FIRES THE MOMENT IT IS A FLICK (match + classic). The swing used to wait for the + // release, so the whole gesture ran as dead time on top of the 60 ms windup — and ~120 ms of + // lateness is all it takes to turn a perfectly aimed return into low-quality scatter. A LEFT + // flick is the slowest direction a right thumb has, and at the service line the opponent's + // return leaves only ~700-870 ms of budget, so "flick left" arrived at the window's tail or + // not at all (Oliver: "almost impossible"). Crossing flickMin already commits the gesture: + // the swing launches HERE, and the vector stays LIVE — trySwing reads G.swipe at contact, so + // the aim keeps following the thumb after the swing is airborne. The release deadline + // (FLICK_MS) is gone with it: a committed swipe that took 320 ms is a swing, not a rescue + // drag — that exception belongs to the finger-steering modes, which never reach this line. + // ax/az ride along so the live vector is drop-in for endTouch's flickAim shape. + // Rally modes only: gore's flick free-aims at a body, not at a lane. + G.swipe = rallyFeel() && dist > TUNE.flickMin + ? { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, + az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, + fx: dx / dist, fy: dy / dist, mag: dist } : null; + if (G.swipe && !stick.swung && G.phase === 'rally') { stick.swung = true; keyPressed('Space'); } } }, { passive: false }); @@ -215,12 +224,18 @@ const endTouch = e => { if (aim && dist >= TUNE.pullMin) dragServe(aim); } else if (!stick.moved && dt < 250) { if (!stick.nearRack) keyPressed('Space'); } // tap — dead if it was a near-miss at the rack - else if (!stick.adopted && dt < FLICK_MS && dist > TUNE.flickMin) { // flick = aimed swing — a rescue drag is never a swing + else if (!stick.adopted && dist > TUNE.flickMin + && (stick.swung || dt < FLICK_MS || (rallyFeel() && G.phase === 'rally'))) { + // flick = aimed swing. The FLICK_MS deadline only still applies where the swing could NOT + // fire at the crossing (gore's free-aim flick, the serve-phase strike) — there a late + // release stays a rescue drag, never a swing. The aim is recorded even when the crossing + // already swung: the ball may arrive AFTER the finger lifts, and takeFlickAim at contact + // is what carries the released vector to it. flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, fx: dx / dist, fy: dy / dist, mag: dist, // raw vector: splatter free-aim t: performance.now() }; - keyPressed('Space'); + if (!stick.swung) keyPressed('Space'); // the crossing already swung — never twice per gesture } stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null; } diff --git a/test.js b/test.js index 53b45c0..292e0d6 100644 --- a/test.js +++ b/test.js @@ -852,6 +852,34 @@ test('a gentle flick cannot be played out — only a committed one can', () => { assert.ok(safe > 0.4 && safe < 1, 'the ring turns hot inside the swipe range, not at either end'); }); +test('the flick aims from the LIVE swipe: contact reads G.swipe, no release needed', () => { + // The swing fires the moment the drag crosses flickMin now (input.js), so at contact the finger + // is usually still down and no released flickAim exists yet. The aim has to come from the live + // vector — without this, every crossing-fired flick would land flat and the latency fix (a left + // flick at the service line released too late to aim anything) would have swung at nothing. + startMatch(); + G.modal = null; G.modalQ.length = 0; + const arm = () => { // one rally ball, contact at the window's centre + G.interlude.introT = 0; G.phase = 'rally'; + P.x = 0; P.z = 22.8; P.swingT = -1; G.placeMark = null; + ball.active = true; ball.lastHit = 'O'; ball.bounces = 1; + ball.x = 0; ball.y = 0.7; ball.z = P.z - 4.15; + ball.vx = 0; ball.vy = 2.0; ball.vz = 14; ball.g = TUNE.gravity; + keyPressed('Space'); // what the flickMin crossing fires mid-gesture + for (let i = 0; i < 30 && ball.lastHit !== 'P'; i++) update(DT); + assert.equal(ball.lastHit, 'P', 'contact happened'); + return G.placeMark; + }; + G.swipe = { ax: -1, az: 0, fx: -1, fy: 0, mag: TUNE.flickFull }; // held LEFT, finger still down + const left = arm(); + assert.ok(left && left.x < -2, 'the live swipe aimed it left: x=' + (left ? left.x.toFixed(2) : 'none')); + G.swipe = { ax: 1, az: 0, fx: 1, fy: 0, mag: TUNE.flickFull }; + const right = arm(); + assert.ok(right && right.x > 2, '...and mirrored right: x=' + (right ? right.x.toFixed(2) : 'none')); + G.swipe = null; // bare tap, no gesture at all + assert.equal(arm(), null, 'no swipe and no released flick: a flat return stamps no mark'); +}); + test('a wounding point knocks the body down and it gets up MINUS the limb', () => { // The beat exists because the wound stage used to POP between points — an arm stopped being there // and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears