From 5bbde720add85da70b1c329094bd491cfbe65e00 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Wed, 14 Oct 2026 11:48:51 +0200 Subject: [PATCH] Time flicks from touch-down, log the opponent's shots, save early half-volleys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Oliver's first swing log (cup vs the Brute): 13 of 22 hits were perfect, 9 badly early or late — mostly forward-right flicks — and the weak balls became seven "passed you". Every hit was a flick, and flicks still swung only at the flickMin crossing while the timed ring says "touch now": they are now timed from touch-down too (capped at flickLead 0.1 s). The log gains its other half: a THEM line per opponent shot (kind, pace, where it lands relative to you, coming in) and "never swung" judged per incoming ball instead of once a point. And an early swing on a low ball digs it out as a capped half-volley instead of whiffing. v366. Co-Authored-By: Claude Opus 5.5 --- PROJECT.md | 25 +++++++++++++++++++++++++ index.html | 2 +- src/config.js | 1 + src/gameplay.js | 32 +++++++++++++++++++++++++++----- src/input.js | 9 +++++++-- src/render.js | 2 +- test.js | 4 ++++ 7 files changed, 66 insertions(+), 9 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 0bbab80..eeaaeb9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5420,6 +5420,31 @@ Oliver: *"bitte bau nochmal die Funktion ein die protokolliert warum ich einen B - Note: contact still fires on the first frame the ball is in reach, which right after a bounce is often 15-25 cm off the grass. A visual half-volley, the old contact model; untouched. +### Oliver's first swing log (v365, cup vs THE BRUTE) and what it changed (2026-10-14, v366) +**His question: "am I just bad or is something off".** Read off the log: +- 22 hits: 13 at q = 1, 9 under 0.5. Six of the nine bad ones were "deep right" (the forward-right + flick), which failed 6 of its 9 tries; the left flick failed 3 of 10. The bad hits split between + EARLY (the ball met at the edge of reach, d 1.55-1.73) and LATE (the ball already on top of him, + d 0.55-0.73). It is timing, with no single offset. +- Weak balls are what the Brute (power, league-5 quality) punishes. Seven LOST "passed you", and + several of them follow a weak hit of his within a second. +- Every hit said `tap 0ms`: he flicks everything. Hypothesis to check in the next log: the gore + slingshot habit. A pull-back before the forward flick crosses `flickMin` DOWNWARD, which fires the + swing early, and the live aim turns to "deep right" by contact. + +**Changes:** +- **Flicks are timed from touch-down too** (`keyPressed(code, lead, flick)`; input.js passes the + crossing's age, capped at `flickLead` 0.1 s so a slow drag keeps a window). The timed ring means + "touch now" for both gestures. The log says `flick 46ms` / `tap 120ms`. Verified with real touch + events. +- **The log's missing half:** a THEM line per opponent shot (kind, spin, q, pace, where it lands + relative to the player, OUT, "coming in"), and `whiffed it` for a CPU flub. "Never swung" is now judged + per incoming ball: `G.swung` and `G.rallyNear` reset at the opponent's strike. It used to be judged + once a point, so none of his "passed you" lines could say whether he swung or how close the ball came. +- **An early swing on a low skidding ball no longer whiffs.** The let-it-bounce gate waits only while + the bounce will land inside the swing window. Otherwise it digs the ball out as a half-volley, capped + below the tape. His 48.14 s MISS was exactly this. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. diff --git a/index.html b/index.html index 0b8a461..89e89ea 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 5409469..c3446ed 100644 --- a/src/config.js +++ b/src/config.js @@ -647,6 +647,7 @@ export const TUNE = { // the swing window, at any pace. 0 = the old distance ring (matchRingSweet/Band) ringBand: 0.35, // share of the half-window either side of the sweet moment that draws gold (edge = q 0.65) ringLead: 0.9, // s before contact the timed ring appears + flickLead: 0.1, // s: a flick is timed from touch-down too, up to this much (a slow drag keeps a window) // ---- THE READ (2026-08-28, Oliver: past the HEAD COACH "the opponents are very fast and barely // make mistakes any more — we need a twist"). The dead staff LEARN YOUR PATTERNS: land the same // lane readHabit times in a row and the opponent has the habit — an EXPECTED ball is anticipated diff --git a/src/gameplay.js b/src/gameplay.js index f1c6973..2cb73bd 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -366,6 +366,20 @@ function doHit(ch, quality, aim) { ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null; ball.from = Math.abs(ch.z - COURT.netZ); ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh + // THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it — + // was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point. + if (ch.side !== 'P' && swingLog()) { + G.swung = false; G.rallyNear = null; + const land = predictLanding(), mark = !land ? '' : (() => { + const dx = land.x - P.x, inside = r2(COURT.L - land.z); + const out = Math.abs(land.x) > COURT.W / 2 || land.z > COURT.L || land.z < COURT.netZ; + return ` · lands ${r2(Math.abs(dx))}m ${dx < 0 ? 'left' : 'right'} of you, ${inside}m inside your line${out ? ' (OUT)' : ''}`; + })(); + const kind = soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '') + : soft === 0 ? 'drop' : soft >= 1 ? 'lob' : 'moonball'; + logSwing({ result: 'THEM', reason: `${kind} q=${r2(quality)} · ${Math.round(Math.hypot(ball.vx, ball.vz))}m/s${mark}` + + (ball.approach === ch.side ? ' · coming in' : '') }); + } G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return ball.pow = quality; // carried pace: zombies splatter only above zSplatQ ball.kills = 0; // fresh shot: multi-kill streak restarts @@ -1603,7 +1617,7 @@ function logWhiff() { else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`; else reason = 'unclassified — in reach, in window, in height band'; // should not happen } - logSwing({ result: 'MISS', reason: reason + ` · tap ${P.tap || 0}ms`, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t), + logSwing({ result: 'MISS', reason: reason + ` · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms`, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t), ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z) }); } @@ -1629,7 +1643,14 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const dx = ball.x - ch.x, dz = ball.z - ch.z; if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return; if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable - if (letBounce()) return; // planned from the back: the swing waits for the bounce instead of a weak pick-up + // planned from the back, the swing WAITS for the bounce instead of a weak pick-up — but only while + // the bounce will still land inside this window. If it will not, this is the last chance and the + // low ball is dug out as a half-volley (the below-the-tape cap prices it): a swing a touch early + // used to WHIFF here, which read as the game eating a good swing (Oliver's log, 2026-10-14). + if (letBounce()) { + const gg = ball.g || TUNE.gravity, tb = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * gg * ball.y)) / gg; + if (t + tb < TUNE.swingWindup + win) return; + } // quality: how centered in the window the contact lands const center = TUNE.swingWindup + win / 2; const off = Math.abs(t - center) / (win / 2); @@ -1684,7 +1705,7 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const sway = swayOf(aim), lane = Math.abs(sway) > 0.15 ? (sway < 0 ? ' left' : ' right') : ''; const cap = q >= q0 ? '' : ball.rx && ball.rx.lob ? ' · capped: lobbed, on your heels' : ball.bounces === 0 ? ' · capped: volley below the tape' : ' · capped: dug out a drop'; - logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''} · tap ${P.tap || 0}ms${cap}`, + logSwing({ result: 'HIT', reason: `q=${r2(q)}${kind}${lane}${volley ? ' volley' : ''} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`, d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) }); } doHit(ch, q, aim); @@ -1802,6 +1823,7 @@ function racketAI(rz, dt) { + (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0); if (Math.random() < flub) { recordEvent('opponent_whiff', { chance: flub, chaseSpeed: rzChaseSpeed(rz), reach: TUNE.aiReach * wnd.reach }); + if (swingLog()) logSwing({ result: 'THEM', reason: 'whiffed it' }); rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder) } else { @@ -1904,7 +1926,7 @@ function meleeCooldown() { TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); } -export function keyPressed(code, lead = 0) { +export function keyPressed(code, lead = 0, flick = false) { // THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no // ball and no scoring, so every other meaning a key has here is meaningless inside it. if (G.bench) return benchKey(code); @@ -2003,7 +2025,7 @@ export function keyPressed(code, lead = 0) { // clock starts that far along: the timing is judged from touch-down, the ball still has to be // there for the contact. P.swingT = rallyFeel() ? lead : 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; - P.tap = Math.round(lead * 1000); // ...and the log says how long the tap was (0 = a key or a flick) + P.tap = Math.round(lead * 1000); P.flick = flick; // ...and the log says which gesture, and how long it took to fire } } diff --git a/src/input.js b/src/input.js index eeb89f6..dea90e4 100644 --- a/src/input.js +++ b/src/input.js @@ -207,7 +207,12 @@ cv.addEventListener('touchmove', e => { ? { 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'); } + // ...timed, like the tap, from the moment the finger LANDED (gameplay.keyPressed's `lead`): the + // timed gold ring means "touch now", and a flick only swings once it has travelled flickMin. + // Capped at flickLead, so a slow drag still has a window to swing into (the left-flick lesson). + if (G.swipe && !stick.swung && G.phase === 'rally') { + stick.swung = true; keyPressed('Space', Math.min(TUNE.flickLead, (performance.now() - stick.t0) / 1000), true); + } } }, { passive: false }); @@ -235,7 +240,7 @@ const endTouch = e => { 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() }; - if (!stick.swung) keyPressed('Space'); // the crossing already swung — never twice per gesture + if (!stick.swung) keyPressed('Space', Math.min(TUNE.flickLead, dt / 1000), true); // the crossing already swung — never twice per gesture } stick = null; touchAxis.x = 0; touchAxis.z = 0; G.swipe = null; } diff --git a/src/render.js b/src/render.js index 705ae0c..f777fa2 100644 --- a/src/render.js +++ b/src/render.js @@ -3317,7 +3317,7 @@ export function render() { ctx.fillStyle = UI.dim; ctx.fillText('SWING LOG — TAP HERE TO COPY · SCOREBOARD HIDES', 10, L.y + 5); const maxCh = Math.floor((W - 20) / 4.3); list.forEach((e, i) => { - ctx.fillStyle = e.result === 'HIT' || e.result === 'WON' ? '#7fd07f' : '#ff9060'; + ctx.fillStyle = e.result === 'HIT' || e.result === 'WON' ? '#7fd07f' : e.result === 'THEM' ? UI.dim : '#ff9060'; ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, L.y + 16 + i * lh); }); } diff --git a/test.js b/test.js index fd2a8db..48faa11 100644 --- a/test.js +++ b/test.js @@ -1390,6 +1390,10 @@ test('THE SWING LOG writes in the match too: a whiff, a hit with its tap, and wh } const hit = G.missLog.find(e => e.result === 'HIT'); assert.ok(hit && /^q=/.test(hit.reason) && /tap 100ms/.test(hit.reason), 'the hit says how it was struck: ' + (hit && hit.reason)); + // ...and the opponent's half is there: what it hit back, and where it lands relative to you + for (let i = 0; i < 2 * 60 && ball.lastHit === 'P'; i++) { zb.swingCool = 0; update(DT); } + const them = G.missLog.find(e => e.result === 'THEM'); + assert.ok(them && (/lands/.test(them.reason) || /whiffed/.test(them.reason)), 'the reply is logged: ' + (them && them.reason)); } finally { TUNE.autoAssist = assist; } });