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:
1 parent
943b7da9fd
commit
d6d00929bb
4 files changed
+80
-2
No files matched your search
@@ -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 };
|
||||
|
||||
+69
-2
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user