Half-volleys: a rising ball is always scoopable; scoreboard tap shows the swing log

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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 21:14:34 +02:00
1 parent d6d00929bb
commit 8fd5055e5c
4 files changed
+17 -3

No files matched your search

+4 -3
View File
@@ -331,7 +331,7 @@ function trySwing(ch, isAI) {
if (ball.lastHit === ch.side) return; if (ball.lastHit === ch.side) return;
const dx = ball.x - ch.x, dz = ball.z - ch.z; 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 (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 // quality: how centered in the window the contact lands
const center = TUNE.swingWindup + win / 2; const center = TUNE.swingWindup + win / 2;
const off = Math.abs(t - center) / (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 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 (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; 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, (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, bz: ball.z, bounces: ball.bounces,
r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) }); 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 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 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
} }
+1
View File
@@ -42,6 +42,7 @@ cv.addEventListener('pointerdown', e => {
const r = cv.getBoundingClientRect(); const r = cv.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width * (cv.width / 2); // logical coords (RES = 2) 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); 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); 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; if (Math.abs(x - u.x) > 16 || y > u.y + 6 || y < u.y - UMP_H * u.s - 8) return;
const now = performance.now(); const now = performance.now();
+11
View File
@@ -294,5 +294,16 @@ export function render() {
ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060'; ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060';
ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); 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'; ctx.textAlign = 'left';
} }
+1
View File
@@ -35,6 +35,7 @@ export const G = {
rallyNear: null, // closest the incoming ball got this rally (for never-swung passes) 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 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 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'; export const other = s => s === 'P' ? 'O' : 'P';