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 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 10:12:48 +02:00
1 parent c176ff6950
commit 65bd187633
1 file changed
+44 -8
+44 -8
View File
@@ -106,7 +106,13 @@ cv.addEventListener('touchcancel', endTouch, { passive: false });
// ----------------------------------------------------------------- audio ---- // ----------------------------------------------------------------- audio ----
let AC = null; 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) { function noiseBuf(ac, len) {
const b = ac.createBuffer(1, ac.sampleRate * len, ac.sampleRate), d = b.getChannelData(0); 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; 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 score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O
const PTS = ['Love', 'Fifteen', 'Thirty', 'Forty']; const PTS = ['Love', 'Fifteen', 'Thirty', 'Forty'];
let phase = 'pretoss'; // pretoss | toss | rally | over 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; let aiServeT = 0, aiSwingCool = 0;
const crowd = { cheer: 0, dots: [] }; const crowd = { cheer: 0, dots: [] };
function buildCrowd() { 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 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; let tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
tx = Math.max(-5, Math.min(5, tx)); tx = Math.max(-5, Math.min(5, tx));
const s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
const v = solveShot(ball.x, ball.y, ball.z, tx, tz, s); 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.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null; ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null;
pock(0.9, 950); pock(0.9, 950);
@@ -311,12 +326,16 @@ let phaseWasServe = false;
function aiIntercept() { function aiIntercept() {
let { x, y, z, vx, vy, vz } = ball; let { x, y, z, vx, vy, vz } = ball;
const g = TUNE.gravity, dt = 0.01; const g = TUNE.gravity, dt = 0.01;
let fallback = null;
for (let i = 0; i < 240; i++) { for (let i = 0; i < 240; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; 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 (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) { function trySwing(ch, isAI) {
@@ -366,6 +385,9 @@ function update(dt) {
if (hitstop > 0) { hitstop -= dt; return; } if (hitstop > 0) { hitstop -= dt; return; }
if (flash) { flash.t -= dt; if (flash.t <= 0) flash = null; } if (flash) { flash.t -= dt; if (flash.t <= 0) flash = null; }
crowd.cheer = Math.max(0, crowd.cheer - dt); 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) // --- player movement (near half only, generous margins)
if (phase === 'rally' || phase === 'toss' || phase === 'pretoss') { 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) { 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; const hpx = 1.88 * p.s;
// pick sprite // pick sprite
const pre = ch.side === 'P' ? 'p_' : 'o_'; const pre = ch.side === 'P' ? 'p_' : 'o_';
let name = pre + 'idle'; 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 === 'swing') name = pre + (ch.poseT < 0.15 ? 'swing1' : 'swing2');
if (ch.pose === 'serve') name = pre + 'serve'; 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]; const img = SPR[name];
// shadow // shadow
ctx.fillStyle = 'rgba(0,0,0,0.28)'; 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.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); 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 // ball shadow + ball
if (ball.active || phase === 'over') { if (ball.active || phase === 'over') {
const sh = proj(ball.x, 0, ball.z); const sh = proj(ball.x, 0, ball.z);