Fix CPU never returning: gate/contact radius mismatch caused guaranteed early whiff

The AI armed its swing at aiReach+0.4 but contact required aiReach, so it
always swung one guaranteed-whiff too early and the 0.4s cooldown consumed
the whole approach window. Also: predictive interception instead of ball-chasing,
shorter whiff recovery, slightly faster legs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 10:02:47 +02:00
1 parent 05c7d636d8
commit c176ff6950
1 file changed
+22 -8
+22 -8
View File
@@ -9,7 +9,7 @@ const TUNE = {
camZ: 36, camY: 9, camF: 280, horizon: 40, camZ: 36, camY: 9, camF: 280, horizon: 40,
gravity: 9.2, // slightly floaty vs 9.8 for readability gravity: 9.2, // slightly floaty vs 9.8 for readability
bounceY: 0.58, bounceXZ: 0.78, bounceY: 0.58, bounceXZ: 0.78,
playerSpeed: 5.4, aiSpeed: 4.4, playerSpeed: 5.4, aiSpeed: 5.0,
reach: 1.5, aiReach: 1.6, reach: 1.5, aiReach: 1.6,
swingWindup: 0.06, // s before contact window opens swingWindup: 0.06, // s before contact window opens
swingWindow: 0.16, // s contact window length swingWindow: 0.16, // s contact window length
@@ -17,7 +17,7 @@ const TUNE = {
serveSpeed: 21, serveQualitySpeed: 7, serveSpeed: 21, serveQualitySpeed: 7,
aimError: 2.4, // m of scatter at quality 0 aimError: 2.4, // m of scatter at quality 0
perfectMs: 45, // |offset from window center| for PERFECT perfectMs: 45, // |offset from window center| for PERFECT
aiQualityMin: 0.5, aiQualityMax: 0.95, aiFlub: 0.06, aiQualityMin: 0.55, aiQualityMax: 0.95, aiFlub: 0.05,
autoAssist: matchMedia('(pointer: coarse)').matches ? 0.65 : 0, // auto-run toward ball x (mobile) autoAssist: matchMedia('(pointer: coarse)').matches ? 0.65 : 0, // auto-run toward ball x (mobile)
}; };
@@ -306,6 +306,19 @@ function onBounce() {
let phaseWasServe = false; let phaseWasServe = false;
// ---------------------------------------------------------------- update ---- // ---------------------------------------------------------------- update ----
// Where should the AI run to? Simulate the ball forward and take the first point
// in its half where the ball is at comfortable hitting height.
function aiIntercept() {
let { x, y, z, vx, vy, vz } = ball;
const g = TUNE.gravity, dt = 0.01;
for (let i = 0; i < 240; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; }
if (i > 5 && z <= 9.5 && y > 0.4 && y < 1.6) return { x, z: Math.max(0.8, z) };
}
return { x: 0, z: 2.5 };
}
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.
const reach = isAI ? TUNE.aiReach : TUNE.reach; const reach = isAI ? TUNE.aiReach : TUNE.reach;
@@ -384,21 +397,21 @@ function update(dt) {
} }
if (phase === 'rally') { if (phase === 'rally') {
const incoming = ball.active && ball.lastHit === 'P'; const incoming = ball.active && ball.lastHit === 'P';
const tx = incoming ? ball.x : 0; const tgt = incoming ? aiIntercept() : { x: 0, z: 2.5 };
const tz = incoming ? Math.max(0.5, Math.min(9.5, ball.z)) : 2.5; const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz);
const dx = tx - O.x, dz = tz - O.z, d = Math.hypot(dx, dz);
if (d > 0.1) { if (d > 0.1) {
O.x += dx / d * Math.min(TUNE.aiSpeed * dt, d); O.x += dx / d * Math.min(TUNE.aiSpeed * dt, d);
O.z += dz / d * Math.min(TUNE.aiSpeed * dt, d) * 0.7; O.z += dz / d * Math.min(TUNE.aiSpeed * dt, d);
O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt; O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt;
if (dx) O.facing = dx > 0 ? 1 : -1; if (dx) O.facing = dx > 0 ? 1 : -1;
} else if (O.pose === 'run') O.pose = 'idle'; } else if (O.pose === 'run') O.pose = 'idle';
aiSwingCool -= dt; aiSwingCool -= dt;
if (incoming && aiSwingCool <= 0 && O.swingT < 0) { if (incoming && aiSwingCool <= 0 && O.swingT < 0) {
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach + 0.4 && ball.y < 2.3 && ball.vz < 0; // same radius trySwing uses for contact, so an armed swing actually connects
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach && ball.y < 2.3 && ball.vz < 0;
if (near) { if (near) {
if (Math.random() < TUNE.aiFlub) aiSwingCool = 0.5; // whiff: hesitates and misses if (Math.random() < TUNE.aiFlub) aiSwingCool = 0.5; // whiff: hesitates and misses
else { O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(O, true); O.swingT = -1; aiSwingCool = 0.4; } else { O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(O, true); O.swingT = -1; aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12; }
} }
} }
} }
@@ -602,4 +615,5 @@ requestAnimationFrame(frame);
// exposed for in-browser sanity checks // exposed for in-browser sanity checks
window.T = { TUNE, ball, P, O, score, solveShot, inServeBox, inCourt, proj, setupPoint, window.T = { TUNE, ball, P, O, score, solveShot, inServeBox, inCourt, proj, setupPoint,
step: dt => update(dt), press: keyPressed,
get phase() { return phase; }, set phase(v) { phase = v; } }; get phase() { return phase; }, set phase(v) { phase = v; } };