From d6d00929bb95e812329c3dd78d1f289094052f90 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 11 Jul 2026 21:02:11 +0200 Subject: [PATCH] Miss log: every tennis swing logs why it failed, last reason shown on HUD Whiffed swings sample the ball's approach each frame and classify the miss at swing expiry: tap too early (with ms), tap too late, ball line wide, too high/low, out of reach. Balls that die on your side without a swing log as PASSED with closest-approach data. Ring buffer of 50 at T.G.missLog; the latest miss reason flashes on the HUD for 3.5s (TUNE.missHud to disable). CPU serve-return benchmark still 11/12. Co-Authored-By: Claude Fable 5 --- config.js | 1 + gameplay.js | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++-- render.js | 4 +++ state.js | 6 +++++ 4 files changed, 80 insertions(+), 2 deletions(-) diff --git a/config.js b/config.js index 2ccb151..5e63fd3 100644 --- a/config.js +++ b/config.js @@ -22,6 +22,7 @@ export const TUNE = { zMax: 6, // shamblers on court at once zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback zPushback: 1.4, // m a staggered zombie is knocked back + missHud: 1, // show the last miss reason on screen (full log: T.G.missLog) }; export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 }; diff --git a/gameplay.js b/gameplay.js index 7882788..670a78c 100644 --- a/gameplay.js +++ b/gameplay.js @@ -152,6 +152,11 @@ function advancePoint(side) { // shared scoring math; returns whether a game was } function pointTo(side, reason) { + if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it + const n = G.rallyNear; + logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`, + dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) }); + } const gameOver = advancePoint(side); G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2; sendBoy(); @@ -266,6 +271,54 @@ function intercept(side) { return fallback || deepest || home; } +// ---------------------------------------------------------- miss diagnostics ---- +// Every player swing in tennis mode gets logged: HIT with quality, or MISS with the +// gate that rejected it and the ball's closest approach. Balls that pass without a +// swing log as PASSED when the point ends. Inspect live via T.G.missLog; the latest +// miss reason is drawn on the HUD (TUNE.missHud). +const r2 = x => Math.round(x * 100) / 100; + +function logSwing(entry) { + entry.at = r2(G.idleT); + G.missLog.push(entry); + if (G.missLog.length > 50) G.missLog.shift(); + if (entry.result !== 'HIT') G.lastMiss = { text: entry.reason, at: G.idleT }; +} + +function logWhiff() { + const snaps = G.swingSnaps; G.swingSnaps = null; + if (G.mode !== 'tennis') return; + if (!snaps || !snaps.length) { + logSwing({ result: 'MISS', reason: !ball.active ? 'no live ball' : 'ball was yours — already hit' }); + return; + } + const best = snaps.reduce((a, b) => (b.d < a.d ? b : a)); + const inReach = snaps.filter(s => s.d <= s.r); + let reason; + if (!inReach.length) { + // never in reach during the swing: was the swing mistimed, or the ball line simply wide? + const tX = ball.active && ball.vz > 0.1 ? (P.z - ball.z) / ball.vz : -1; // time until ball reaches your depth + const lat = tX > 0 ? Math.abs(ball.x + ball.vx * tX - P.x) : null; // how wide it will pass + const nearD = G.rallyNear ? G.rallyNear.d : 1e9; // closest true pass so far this rally + if (lat !== null && lat > best.r) reason = `ball line ${r2(lat)}m wide of you — run, don't swing`; + else if (tX > 0) reason = `tap too early — ball still ${r2(best.d)}m out, arrives ${Math.round(tX * 1000)}ms after swing ended`; + else if (nearD > best.r) reason = `ball passed ${r2(nearD)}m wide of you — never reachable from where you stood`; + else reason = `tap too late — ball already gone (it came within ${r2(nearD)}m of you earlier)`; + } else { + const inWin = inReach.filter(s => s.t >= TUNE.swingWindup && s.t <= TUNE.swingWindup + s.w); + if (!inWin.length) { + const first = inReach[0], last = inReach[inReach.length - 1]; + reason = last.t < TUNE.swingWindup + ? `tap too late — ball already passing ${Math.round((TUNE.swingWindup - last.t) * 1000)}ms before swing opened` + : `tap too early — ball arrived ${Math.round((first.t - (TUNE.swingWindup + first.w)) * 1000)}ms after window closed`; + } else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`; + 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, 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) }); +} + function trySwing(ch, isAI) { // Contact happens if the ball is inside reach while the swing window is open. // Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue. @@ -290,6 +343,10 @@ function trySwing(ch, isAI) { ? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } : flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (TUNE.aiQualityMax - TUNE.aiQualityMin)); + if (!isAI && G.mode === 'tennis') { + G.swingSnaps = null; G.rallyNear = null; + logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 }); + } doHit(ch, q, aim); ch.swingT = -1; } @@ -319,7 +376,7 @@ export function keyPressed(code) { hitServe(P, q); return; } - if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; } + if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; } } function expirePose(ch, dt) { @@ -343,6 +400,10 @@ export function update(dt) { // --- player movement (near half only, generous margins) if (G.phase === 'rally') { + if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes + const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); + if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z }; + } const slow = P.swingT >= 0 ? 0.35 : 1; const mx = axisX(), mz = axisZ(); P.x += mx * TUNE.playerSpeed * slow * dt; @@ -369,9 +430,15 @@ 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 + 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 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(); } + 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 } expirePose(P, dt); diff --git a/render.js b/render.js index 1a936db..cd6be7a 100644 --- a/render.js +++ b/render.js @@ -290,5 +290,9 @@ export function render() { ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1); } + if (TUNE.missHud && G.lastMiss && G.idleT - G.lastMiss.at < 3.5) { // last miss reason, live + ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ff9060'; + ctx.fillText(G.lastMiss.text.toUpperCase(), W / 2, H - 28); + } ctx.textAlign = 'left'; } diff --git a/state.js b/state.js index 72b4981..8275aa1 100644 --- a/state.js +++ b/state.js @@ -30,6 +30,11 @@ export const G = { wasServe: false, // the ball in flight is a serve (first bounce must hit the box) splatT: 0, // time survived in splatter mode — drives the difficulty ramp eatenT: 0, // time since you were eaten — gates the restart tap + missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog + swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log + 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 }; export const other = s => s === 'P' ? 'O' : 'P'; @@ -37,6 +42,7 @@ export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even export function setupPoint() { G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.firstLand = null; + G.swingSnaps = null; G.rallyNear = null; G.swung = false; if (G.mode === 'splatter') { // always your serve, from wherever you're standing score.server = 'P'; P.pose = 'idle'; P.swingT = -1; P.facing = 1;