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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 21:02:11 +02:00
1 parent 943b7da9fd
commit d6d00929bb
4 files changed
+80 -2

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ export const TUNE = {
zMax: 6, // shamblers on court at once zMax: 6, // shamblers on court at once
zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback zSplatQ: 0.5, // min shot quality to splatter; below = stagger pushback
zPushback: 1.4, // m a staggered zombie is knocked back 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 }; export const COURT = { L: 23.77, W: 8.23, dblW: 10.97, netZ: 11.885, netH: 0.914, netW: 10.06 };
+69 -2
View File
@@ -152,6 +152,11 @@ function advancePoint(side) { // shared scoring math; returns whether a game was
} }
function pointTo(side, reason) { 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); const gameOver = advancePoint(side);
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2; G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
sendBoy(); sendBoy();
@@ -266,6 +271,54 @@ function intercept(side) {
return fallback || deepest || home; 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) { function trySwing(ch, isAI) {
// Contact happens if the ball is inside reach while the swing window is open. // 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. // 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 } ? { 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) }; : 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) 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); doHit(ch, q, aim);
ch.swingT = -1; ch.swingT = -1;
} }
@@ -319,7 +376,7 @@ export function keyPressed(code) {
hitServe(P, q); hitServe(P, q);
return; 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) { function expirePose(ch, dt) {
@@ -343,6 +400,10 @@ export function update(dt) {
// --- player movement (near half only, generous margins) // --- player movement (near half only, generous margins)
if (G.phase === 'rally') { 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 slow = P.swingT >= 0 ? 0.35 : 1;
const mx = axisX(), mz = axisZ(); const mx = axisX(), mz = axisZ();
P.x += mx * TUNE.playerSpeed * slow * dt; 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 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
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 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 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); expirePose(P, dt);
+4
View File
@@ -290,5 +290,9 @@ export function render() {
ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff';
ctx.fillText(G.flash.text, W / 2, 60 + Math.sin(G.flash.t * 20) * 1); 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'; ctx.textAlign = 'left';
} }
+6
View File
@@ -30,6 +30,11 @@ export const G = {
wasServe: false, // the ball in flight is a serve (first bounce must hit the box) 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 splatT: 0, // time survived in splatter mode — drives the difficulty ramp
eatenT: 0, // time since you were eaten — gates the restart tap 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'; 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() { export function setupPoint() {
G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.firstLand = null; 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 if (G.mode === 'splatter') { // always your serve, from wherever you're standing
score.server = 'P'; score.server = 'P';
P.pose = 'idle'; P.swingT = -1; P.facing = 1; P.pose = 'idle'; P.swingT = -1; P.facing = 1;