From 8fd5055e5cb58f70a1e18aaa527c210567f79a16 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 11 Jul 2026 21:14:34 +0200 Subject: [PATCH] Half-volleys: a rising ball is always scoopable; scoreboard tap shows the swing log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field data (miss log) showed the dominant unfair miss: the assist parks you on the landing spot, the ball spends the whole swing window below minY rising off the bounce at your feet, and becomes hittable one frame after the window closes. Root fix: the below-ankles rejection now only applies to a DROPPING ball — rising post-bounce balls can be scooped at any height (a half-volley). Feet-bounce taps go from ~0 to 19/30 winning frames; 30 simulated real points now log 50 hits, 0 whiffs. Also: swing-log snapshots record the same swing-timer value trySwing tests (removes the one-frame misclassification behind 'unclassified'), and tapping the scoreboard toggles an on-screen dump of the last 9 log entries so live situations can be screenshotted from the phone. Co-Authored-By: Claude Fable 5 --- gameplay.js | 7 ++++--- main.js | 1 + render.js | 11 +++++++++++ state.js | 1 + 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/gameplay.js b/gameplay.js index 670a78c..89917cb 100644 --- a/gameplay.js +++ b/gameplay.js @@ -331,7 +331,7 @@ function trySwing(ch, isAI) { if (ball.lastHit === ch.side) return; 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) return; // below the ankles the ball is past you — no grass-scraping saves + 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 // quality: how centered in the window the contact lands const center = TUNE.swingWindup + win / 2; const off = Math.abs(t - center) / (win / 2); @@ -430,13 +430,14 @@ export function update(dt) { else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing } if (P.swingT >= 0) { - if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log + P.swingT += dt; + if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame) const volley = ball.bounces === 0; (G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y, bz: ball.z, bounces: ball.bounces, r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) }); } - P.swingT += dt; trySwing(P, false); // a contact resets swingT to -1 inside trySwing + trySwing(P, false); // a contact resets swingT to -1 inside trySwing const win = TUNE.swingWindow * (ball.active && ball.bounces === 0 ? VOLLEY.window : 1); // match trySwing's volley window if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing } diff --git a/main.js b/main.js index 54cddc2..ab2eb08 100644 --- a/main.js +++ b/main.js @@ -42,6 +42,7 @@ cv.addEventListener('pointerdown', e => { const r = cv.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width * (cv.width / 2); // logical coords (RES = 2) const y = (e.clientY - r.top) / r.height * (cv.height / 2); + if (x >= 6 && x <= 138 && y >= 6 && y <= 36) { G.logShow = !G.logShow; return; } // scoreboard tap = miss-log overlay const u = proj(7.1, 0, COURT.netZ); if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return; const now = performance.now(); diff --git a/render.js b/render.js index cd6be7a..a509adf 100644 --- a/render.js +++ b/render.js @@ -294,5 +294,16 @@ export function render() { ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060'; ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); } + if (G.logShow) { // scoreboard tap: recent swing diagnostics, screenshot-friendly + const list = G.missLog.slice(-9), lh = 10, top = 40; + ctx.fillStyle = 'rgba(8,10,16,0.88)'; ctx.fillRect(6, top, W - 12, list.length * lh + 16); + ctx.font = 'bold 7px monospace'; ctx.textAlign = 'left'; + ctx.fillStyle = '#8fa0b8'; ctx.fillText('SWING LOG — tap scoreboard to hide', 10, top + 5); + const maxCh = Math.floor((W - 20) / 4.3); + list.forEach((e, i) => { + ctx.fillStyle = e.result === 'HIT' ? '#7fd07f' : '#ff9060'; + ctx.fillText((e.at + 's ' + e.result + ' ' + e.reason).slice(0, maxCh), 10, top + 16 + i * lh); + }); + } ctx.textAlign = 'left'; } diff --git a/state.js b/state.js index 8275aa1..5768334 100644 --- a/state.js +++ b/state.js @@ -35,6 +35,7 @@ export const G = { rallyNear: null, // closest the incoming ball got this rally (for never-swung passes) swung: false, // player swung at least once this rally — whiff log supersedes PASSED lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly + logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump }; export const other = s => s === 'P' ? 'O' : 'P';