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 ----
|
// ----------------------------------------------------------------- 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);
|
||||||
|
|||||||
Reference in new issue
Block a user