From 65bd1876336fae021c6a2ac10ba14bc6842a5bb7 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 11 Jul 2026 10:12:48 +0200 Subject: [PATCH] CPU returns clear the net; serve/idle/run animations; iOS speech unlock - doHit now trades pace for loft when contact is low, margin scales with shot quality; AI prefers waist-high interception instead of ankle-height - serve stance held through toss, run cycle with bob, receiver split-step, server bounces the ball while waiting - speechSynthesis unlocked inside first touch gesture for iOS Co-Authored-By: Claude Fable 5 --- game.js | 52 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/game.js b/game.js index c3b2b07..d4c8b35 100644 --- a/game.js +++ b/game.js @@ -106,7 +106,13 @@ cv.addEventListener('touchcancel', endTouch, { passive: false }); // ----------------------------------------------------------------- audio ---- let AC = null; -const audio = () => AC || (AC = new (window.AudioContext || window.webkitAudioContext)()); +const audio = () => { + if (!AC) { + AC = new (window.AudioContext || window.webkitAudioContext)(); + try { speechSynthesis.resume(); speechSynthesis.speak(new SpeechSynthesisUtterance(' ')); } catch (e) {} // iOS: unlock voice inside a user gesture + } + return AC; +}; function noiseBuf(ac, len) { const b = ac.createBuffer(1, ac.sampleRate * len, ac.sampleRate), d = b.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; @@ -170,7 +176,7 @@ const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, active: false, lastHit: ' const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O const PTS = ['Love', 'Fifteen', 'Thirty', 'Forty']; let phase = 'pretoss'; // pretoss | toss | rally | over -let serveNum = 1, overT = 0, hitstop = 0, flash = null; // flash = {text,x,y,t} +let serveNum = 1, overT = 0, hitstop = 0, flash = null, idleT = 0; // flash = {text,x,y,t} let aiServeT = 0, aiSwingCool = 0; const crowd = { cheer: 0, dots: [] }; function buildCrowd() { @@ -210,8 +216,17 @@ function doHit(ch, quality, aim) { let tzBase = aim.az < 0 ? 3.0 : aim.az > 0 ? 8.6 : 5.4; // depth from target baseline let tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; tx = Math.max(-5, Math.min(5, tx)); - const s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; - const v = solveShot(ball.x, ball.y, ball.z, tx, tz, s); + let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; + let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s); + // low contact → trade pace for loft until the net is cleared; good timing gets a + // comfortable margin, sloppy timing keeps some tape risk + const margin = 0.06 + 0.22 * quality; + for (let i = 0; i < 8; i++) { + const tN = (COURT.netZ - ball.z) / v.vz; + const yN = ball.y + v.vy * tN - 0.5 * TUNE.gravity * tN * tN; + if (tN <= 0 || yN >= COURT.netH + margin) break; + s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s); + } ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null; pock(0.9, 950); @@ -311,12 +326,16 @@ let phaseWasServe = false; function aiIntercept() { let { x, y, z, vx, vy, vz } = ball; const g = TUNE.gravity, dt = 0.01; + let fallback = null; 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) }; + if (i > 5 && z <= 9.5 && y < 1.6) { + if (y > 0.7) return { x, z: Math.max(0.8, z) }; // waist-high contact preferred + if (y > 0.3 && !fallback) fallback = { x, z: Math.max(0.8, z) }; + } } - return { x: 0, z: 2.5 }; + return fallback || { x: 0, z: 2.5 }; } function trySwing(ch, isAI) { @@ -366,6 +385,9 @@ function update(dt) { if (hitstop > 0) { hitstop -= dt; return; } if (flash) { flash.t -= dt; if (flash.t <= 0) flash = null; } crowd.cheer = Math.max(0, crowd.cheer - dt); + idleT += dt; + if (phase === 'pretoss' && Math.sin(idleT * 3.6) * Math.sin((idleT - dt) * 3.6) < 0) pock(0.12, 420); // server bouncing the ball + if (phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } // --- player movement (near half only, generous margins) if (phase === 'rally' || phase === 'toss' || phase === 'pretoss') { @@ -464,14 +486,18 @@ function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trap } function drawChar(ch) { - const p = proj(ch.x, 0, ch.z); + const receiverWaiting = phase === 'pretoss' && ch.side !== score.server && ch.pose === 'idle'; + const sway = receiverWaiting ? Math.sin(idleT * 3.2) : 0; + const p = proj(ch.x + sway * 0.12, 0, ch.z); const hpx = 1.88 * p.s; // pick sprite const pre = ch.side === 'P' ? 'p_' : 'o_'; let name = pre + 'idle'; - if (ch.pose === 'run') name = pre + (Math.floor(ch.runT * 8) % 2 ? 'run1' : 'run2'); + if (ch.pose === 'run') name = pre + (Math.floor(ch.runT * 9) % 2 ? 'run1' : 'run2'); + if (receiverWaiting) name = pre + (sway > 0 ? 'run1' : 'run2'); // split-step shuffle if (ch.pose === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2'); if (ch.pose === 'serve') name = pre + 'serve'; + if (ch.pose === 'run') p.y -= Math.floor(ch.runT * 18) % 2; // 1px run bob const img = SPR[name]; // shadow ctx.fillStyle = 'rgba(0,0,0,0.28)'; @@ -548,6 +574,16 @@ function render() { ctx.beginPath(); ctx.moveTo(nlt.x, nlt.y); ctx.lineTo(nrt.x, nrt.y); ctx.stroke(); ctx.fillStyle = '#333'; ctx.fillRect(nl.x - 1, nlt.y, 2, nl.y - nlt.y); ctx.fillRect(nr.x - 1, nrt.y, 2, nr.y - nrt.y); + // server idly bouncing the ball while waiting + if (phase === 'pretoss') { + const sv = score.server === 'P' ? P : O; + const bx = sv.x - 0.4 * (sv.side === 'P' ? 1 : -1), by = Math.abs(Math.sin(idleT * 3.6)) * 0.55; + const sh = proj(bx, 0, sv.z); + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + ctx.beginPath(); ctx.ellipse(sh.x, sh.y, Math.max(1.5, sh.s * 0.08), Math.max(1, sh.s * 0.035), 0, 0, 7); ctx.fill(); + const bp = proj(bx, by, sv.z); + ctx.fillStyle = '#e8f03a'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1.5, bp.s * 0.09), 0, 7); ctx.fill(); + } // ball shadow + ball if (ball.active || phase === 'over') { const sh = proj(ball.x, 0, ball.z);