diff --git a/PROJECT.md b/PROJECT.md index 6b2c281..d964f03 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -597,6 +597,24 @@ The suite pins the direction guarantee and the knob relationship (`aimTrust·aim under a committed swipe's worst-case aim). Whether placement should also BEAT an unwounded body more often (`aiRead` on wide balls — "dial 2") is open, awaiting the phone verdict on this one. +**...and the flick aims RELATIVE TO THE PLAYER** (2026-08-20, third round, and this was the core +all along: *"when the opponent and the player are both standing in the left service box it is +virtually impossible to flick the ball into the left out. no matter how hard/good I flick"*). The +lane was ABSOLUTE — a full flick asked for `flickWidth` off the court's CENTRE — so the gesture +saturated near a sideline: from x −2 a perfect left flick moved the ball under 2 m and could never +pass the line (by design, 3.95 vs 4.115 — and the better the timing, the firmer the cap, because +scatter was the only thing that could cross). The felt "left is impossible / right works" was +never left-vs-right: it was toward-the-near-sideline vs across-the-court. **`TUNE.aimAnchor`**: +the aim's anchor slides from the court centre to the player's own x as the swipe commits +(`anchor = ch.x · |sway| · aimAnchor`), so a flat tap is exactly what it always was and a full +flick is a full ±flickWidth sweep FROM WHERE YOU STAND — including past the line. **Out-by-aim +exists now and is a choice** (his explicit direction: *"not about the possibility of the ball +going out but rather the aiming itself"*); the placement ring already turns red for it. 0 restores +the old absolute lane, blendable, panel-tunable for live A/B. Player rally lanes only — the +opponent's lanes and the pOut invariants are stated from centre and unchanged there. Verified in +his exact scene (both bodies in their left boxes): full left flick lands −5 (the alley), half +swipe −3.07 (hunting the line, in), full right +1.93 (a full sweep measured from him). + **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 738a0e0..cd01c6f 100644 --- a/index.html +++ b/index.html @@ -659,6 +659,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/config.js b/src/config.js index 82555a4..1f38c0d 100644 --- a/src/config.js +++ b/src/config.js @@ -64,6 +64,14 @@ export const TUNE = { // send the ball longer — solveShot still lands it on the same spot — it // gets there flatter and sooner, which is what a smash is. aimError: 2.4, // m of scatter at quality 0 + aimAnchor: 1, // how much the flick's lane follows the PLAYER instead of the court. + // 0 = the old absolute lane (a full flick asks for flickWidth off the + // court's centre — saturates near a sideline: from x -2 a perfect left + // flick moved the ball under 2 m and could never find the line). 1 = the + // anchor slides to your own x as the swipe commits, so a full flick is a + // full sweep from where you stand — including past the line: out-by-aim + // is a choice now, and the ring already turns red for it. Blendable so + // the panel can A/B the feel live. Player's rally lanes only. aimTrust: 0.3, // an aimed rally shot TRUSTS its flick: the scatter may still push the // ball further OUT (the chosen out-risk keeps its exact shape) but only // this fraction of an INWARD roll survives, so a mistimed left flick diff --git a/src/gameplay.js b/src/gameplay.js index 7c02991..a0acbd8 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -189,7 +189,20 @@ function doHit(ch, quality, aim) { // symmetric scatter is its width mechanic (matchWide clamps it, not this). let e = err(); if (ch.side === 'P' && sway && e * Math.sign(sway) < 0) e *= TUNE.aimTrust; - tx = sway * (toFar ? TUNE.flickWidth : 2.7) * reach + e; + // THE FLICK AIMS RELATIVE TO YOU, not to the court (2026-08-20, third round of the same + // report: "when the opponent and the player are both standing in the left service box it is + // virtually impossible to flick the ball into the left out. no matter how hard/good I flick"). + // The lane was ABSOLUTE — a full flick asked for 3.95 m from the court's centre — so standing + // near a sideline saturated the gesture: from x -2 a perfect left flick moved the ball under + // 2 m and could never pass the line, while the same swipe from centre swept the court. The + // anchor now slides from centre to the player's own x as the swipe commits (|sway|), so a + // flat tap is exactly what it always was and a full flick is a full sweep FROM WHERE YOU + // STAND — including past the line: out-by-aim is his explicit ask ("not about the possibility + // of the ball going out but rather the aiming itself"). TUNE.aimAnchor blends it (0 = the old + // absolute lane, 1 = fully relative) so the panel can A/B the feel live. Player only; the + // opponent's lanes and every pOut invariant are stated from centre and unchanged there. + const anchor = ch.side === 'P' ? ch.x * Math.abs(sway) * TUNE.aimAnchor : 0; + tx = anchor + sway * (toFar ? TUNE.flickWidth : 2.7) * reach + e; const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; // A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for diff --git a/test.js b/test.js index cbab5f7..1f491d8 100644 --- a/test.js +++ b/test.js @@ -876,6 +876,40 @@ test('an aimed shot keeps its DIRECTION: scatter may push it wider, never back p 'aimTrust*aimError must stay under a committed swipe\'s worst-case aim'); }); +test('the flick aims RELATIVE TO THE PLAYER: a full sweep from where you stand, not from centre', () => { + // Third round of the service-line report: the lane was absolute, so standing near a sideline + // saturated the gesture — from the left box a perfect left flick moved the ball under 2 m and + // could never find the line ("no matter how hard/good I flick"). The anchor follows the player + // as the swipe commits (TUNE.aimAnchor); a flat tap keeps its old centre default. + startMatch(); + G.modal = null; G.modalQ.length = 0; + const arm = (px, swipe) => { // one rally ball, contact at the window's centre + G.interlude.introT = 0; G.phase = 'rally'; + P.x = px; P.z = 22.8; P.swingT = -1; G.placeMark = null; + ball.active = true; ball.lastHit = 'O'; ball.bounces = 1; + ball.x = px; ball.y = 0.7; ball.z = P.z - 4.15; + ball.vx = 0; ball.vy = 2.0; ball.vz = 14; ball.g = TUNE.gravity; + G.swipe = swipe; + keyPressed('Space'); + for (let i = 0; i < 30 && ball.lastHit !== 'P'; i++) update(DT); + assert.equal(ball.lastHit, 'P', 'contact happened'); + G.swipe = null; + return G.placeMark; + }; + const full = fx => ({ ax: Math.sign(fx), az: 0, fx, fy: 0, mag: TUNE.flickFull }); + const line = COURT.W / 2; + // from the LEFT service box, a committed left flick reaches PAST the line — out is aimable now + const left = arm(-2, full(-1)); + assert.ok(left && left.x < -line, 'full left flick from the left box finds the alley: x=' + left.x.toFixed(2)); + // ...and a right flick from the same spot lands a full sweep right OF THE PLAYER, near centre-right + const right = arm(-2, full(1)); + assert.ok(right && right.x > 1.2 && right.x < 2.8, 'full right flick sweeps from the player: x=' + right.x.toFixed(2)); + // an unaimed tap is untouched by where you stand: no mark, and the ball heads for the centre + assert.equal(arm(-2, null), null, 'a flat tap from the box stamps no mark, exactly as before'); + assert.ok(Math.abs(ball.vx * ((3 - ball.z) / ball.vz) + ball.x) < TUNE.aimError + 1, + 'and its line stays the old centre default, not the player lane'); +}); + 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