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:
1 parent
c176ff6950
commit
65bd187633
1 file changed
+44
-8
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user