diff --git a/game.js b/game.js index 5fbcc79..c3b2b07 100644 --- a/game.js +++ b/game.js @@ -9,7 +9,7 @@ const TUNE = { camZ: 36, camY: 9, camF: 280, horizon: 40, gravity: 9.2, // slightly floaty vs 9.8 for readability bounceY: 0.58, bounceXZ: 0.78, - playerSpeed: 5.4, aiSpeed: 4.4, + playerSpeed: 5.4, aiSpeed: 5.0, reach: 1.5, aiReach: 1.6, swingWindup: 0.06, // s before contact window opens swingWindow: 0.16, // s contact window length @@ -17,7 +17,7 @@ const TUNE = { serveSpeed: 21, serveQualitySpeed: 7, aimError: 2.4, // m of scatter at quality 0 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) }; @@ -306,6 +306,19 @@ function onBounce() { let phaseWasServe = false; // ---------------------------------------------------------------- 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) { // Contact happens if the ball is inside reach while the swing window is open. const reach = isAI ? TUNE.aiReach : TUNE.reach; @@ -384,21 +397,21 @@ function update(dt) { } if (phase === 'rally') { const incoming = ball.active && ball.lastHit === 'P'; - const tx = incoming ? ball.x : 0; - const tz = incoming ? Math.max(0.5, Math.min(9.5, ball.z)) : 2.5; - const dx = tx - O.x, dz = tz - O.z, d = Math.hypot(dx, dz); + const tgt = incoming ? aiIntercept() : { x: 0, z: 2.5 }; + const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz); if (d > 0.1) { 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; if (dx) O.facing = dx > 0 ? 1 : -1; } else if (O.pose === 'run') O.pose = 'idle'; aiSwingCool -= dt; 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 (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 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; } };