Topspin pace model, beatable CPU, boy run cycle, umpire reverb, z-order fix

- fake topspin: quality shots dive under extra effective gravity, so real pace
  (up to 28 m/s) can clear the net AND land in — without it the net-clearance
  loop silently slowed every shot to a coverable lob, which is why the CPU
  returned everything regardless of aim
- no grass-scraping: balls below ankle height (0.24m) are past you, both sides
- CPU: 0.45s shot-read delay, 4.3 m/s legs, half-speed recovery between shots.
  Verified gradient: perfect/good corner shots are winners, center balls and
  weak shots still come back
- player aimed shots go wider (3.3m vs 2.7m)
- ball boy: nano-banana mirrored stride frames (was stride-vs-hop), height
  1.2m, z-ordered against umpire and CPU (no more running over the chair)
- umpire voice through a synthesized convolver reverb — center-court PA echo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 14:41:58 +02:00
1 parent f3c2af4ef5
commit 456e168c27
4 files changed
+52 -26

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

After

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

+52 -26
View File
@@ -8,16 +8,18 @@
const TUNE = {
camZ: 36, camY: 9, camF: 280, horizon: 40,
gravity: 9.2, // slightly floaty vs 9.8 for readability
spinFactor: 0.9, // extra dive on quality shots (fake topspin)
bounceY: 0.58, bounceXZ: 0.78,
playerSpeed: 5.4, aiSpeed: 5.0,
playerSpeed: 5.4, aiSpeed: 4.3,
reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest
swingWindup: 0.06, // s before contact window opens
swingWindow: 0.16, // s contact window length
baseShotSpeed: 15, qualityShotSpeed: 9, // horizontal m/s = base + q*quality
baseShotSpeed: 16, qualityShotSpeed: 12, // horizontal m/s = base + q*quality; perfect timing = real pace
serveSpeed: 21, serveQualitySpeed: 7,
aimError: 2.4, // m of scatter at quality 0
perfectMs: 45, // |offset from window center| for PERFECT
aiQualityMin: 0.55, aiQualityMax: 0.95, aiFlub: 0.05,
aiQualityMin: 0.45, aiQualityMax: 0.85, aiFlub: 0.09,
aiReaction: 0.45, // s before the CPU reads your shot — well-aimed corners are winners
autoAssist: matchMedia('(pointer: coarse)').matches ? 1.15 : 0, // auto-run speed factor (mobile); slightly superhuman legs
};
@@ -116,11 +118,25 @@ const audio = () => {
// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio —
// works on mobile where speechSynthesis is unreliable. say() remains the fallback.
const VOICE = {};
let reverbNode = null;
function reverb(ac) { // stadium PA tail: exponentially decaying noise as impulse response
if (reverbNode) return reverbNode;
const len = ac.sampleRate * 1.3, ir = ac.createBuffer(2, len, ac.sampleRate);
for (let c = 0; c < 2; c++) {
const d = ir.getChannelData(c);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.8);
}
reverbNode = ac.createConvolver(); reverbNode.buffer = ir;
const wet = ac.createGain(); wet.gain.value = 0.35;
reverbNode.connect(wet).connect(ac.destination);
return reverbNode;
}
function voice(name, delay = 0) {
const ac = audio();
const play = buf => {
const src = ac.createBufferSource(); src.buffer = buf;
src.connect(ac.destination); src.start(ac.currentTime + delay);
src.connect(ac.destination); src.connect(reverb(ac)); // dry + center-court echo
src.start(ac.currentTime + delay);
};
if (VOICE[name]) return play(VOICE[name]);
fetch('assets/audio/' + name + '.m4a')
@@ -212,7 +228,7 @@ function sendBoy() {
const dir = ball.x > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run
boy = { x: dir > 0 ? -13 : 13, z: ball.z, dir, runT: 0 };
}
let aiServeT = 0, aiSwingCool = 0;
let aiServeT = 0, aiSwingCool = 0, aiReactT = 9;
const crowd = { cheer: 0, dots: [] };
function buildCrowd() {
crowd.dots.length = 0;
@@ -237,10 +253,10 @@ function setupPoint() {
}
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s. Returns velocity.
function solveShot(x0, y0, z0, tx, tz, s) {
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity) {
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
return { vx: dx / T, vz: dz / T, vy: (0.08 - y0 + 0.5 * TUNE.gravity * T * T) / T };
return { vx: dx / T, vz: dz / T, vy: (0.08 - y0 + 0.5 * g * T * T) / T };
}
function doHit(ch, quality, aim) {
@@ -250,26 +266,31 @@ function doHit(ch, quality, aim) {
ball.x = ch.x + ch.facing * 0.5;
ball.z = ch.z + (toFar ? -0.3 : 0.3);
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
let tx = aim.ax * 2.7 + err();
let tx = aim.ax * (ch.side === 'P' ? 3.3 : 2.7) + err(); // your aimed shots go properly wide
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));
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s);
// topspin: quality shots dive under extra effective gravity, which is what lets real
// pace arc over the net and still land in — the core tennis trade-off
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
// 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;
const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN;
if (tN <= 0 || yN >= COURT.netH + margin) break;
s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s);
s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
}
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null;
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
const ok = Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ;
outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
aiReactT = 0; // CPU hasn't read the shot yet
}
pock(0.9, 950);
if (quality > 0.999) { hitstop = 0.05; flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); }
@@ -280,7 +301,7 @@ function doHit(ch, quality, aim) {
function tossBall(sv) {
ball.active = true; ball.bounces = 0; ball.firstLand = null; ball.collected = false;
ball.x = sv.x + 0.35 * (sv.side === 'P' ? 1 : -1); ball.z = sv.z; ball.y = 1.8;
ball.vx = 0; ball.vz = 0; ball.vy = 4.6;
ball.vx = 0; ball.vz = 0; ball.vy = 4.6; ball.g = TUNE.gravity;
phase = 'toss';
}
function serveApexY() { return 1.8 + 4.6 * 4.6 / (2 * TUNE.gravity); } // ~2.95
@@ -291,7 +312,9 @@ function hitServe(sv, quality) {
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
const tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s);
const g = TUNE.gravity * (1 + quality * 0.35); // kick serves dip a little too
const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
ball.g = g;
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
ball.lastHit = sv.side; ball.bounces = 0;
if (sv.side === 'P') outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
@@ -374,7 +397,7 @@ function intercept(side) {
const ch = side === 'O' ? O : P;
const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist;
let { x, y, z, vx, vy, vz } = ball;
const g = TUNE.gravity, dt = 0.01;
const g = ball.g || TUNE.gravity, dt = 0.01;
const inZone = side === 'O' ? zz => zz <= 9.5 : zz => zz >= COURT.L - 9.5;
const home = side === 'O' ? { x: 0, z: 2.5 } : { x: 0, z: 22.8 };
let fallback = null, deepest = null;
@@ -401,6 +424,7 @@ function trySwing(ch, isAI) {
if (ball.lastHit === ch.side) return;
const dx = ball.x - ch.x, dz = ball.z - ch.z;
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > 2.3) return;
if (ball.y < 0.24) return; // below the ankles the ball is past you — no grass-scraping saves
// quality: how centered in the window the contact lands
const center = TUNE.swingWindup + TUNE.swingWindow / 2;
const off = Math.abs(t - center) / (TUNE.swingWindow / 2);
@@ -486,19 +510,21 @@ function update(dt) {
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { phaseWasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); }
}
if (phase === 'rally') {
const incoming = ball.active && ball.lastHit === 'P';
aiReactT += dt;
const incoming = ball.active && ball.lastHit === 'P' && aiReactT >= TUNE.aiReaction;
const tgt = incoming ? intercept('O') : { x: 0, z: 2.5 };
const spd = TUNE.aiSpeed * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points
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);
O.x += dx / d * Math.min(spd * dt, d);
O.z += dz / d * Math.min(spd * dt, d);
O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt;
if (Math.abs(dx) > 0.25) O.facing = dx > 0 ? 1 : -1;
} else if (O.pose === 'run') O.pose = 'idle';
aiSwingCool -= dt;
if (incoming && aiSwingCool <= 0 && O.swingT < 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;
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach && ball.y < 2.3 && ball.y > 0.24 && 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 = ball.lastHit === 'O' ? 0.4 : 0.12; }
@@ -509,7 +535,7 @@ function update(dt) {
// --- ball physics
if (ball.active) {
ball.vy -= TUNE.gravity * dt;
ball.vy -= (ball.g || TUNE.gravity) * dt;
const oldZ = ball.z;
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
// net: plane at netZ, only during rally flight
@@ -554,7 +580,7 @@ function update(dt) {
function predictLanding() { // first-bounce point of the ball's current trajectory
let { x, y, z, vx, vy, vz } = ball;
const g = TUNE.gravity, dt = 0.01;
const g = ball.g || TUNE.gravity, dt = 0.01;
for (let i = 0; i < 300; i++) {
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
if (y <= 0) return { x, z };
@@ -586,7 +612,7 @@ function drawStatic(name, x, z, hMeters, bob = 0) {
function drawBoy(b) {
const img = SPR[Math.floor(b.runT * 10) % 2 ? 'x_boyrun1' : 'x_boyrun2']; if (!img) return;
const p = proj(b.x, 0, b.z);
const h = 1.35 * p.s, w = h * img.width / img.height;
const h = 1.2 * p.s, w = h * img.width / img.height;
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath(); ctx.ellipse(p.x, p.y, w * 0.3, Math.max(1, h * 0.04), 0, 0, 7); ctx.fill();
ctx.save(); ctx.translate(p.x, p.y - (Math.floor(b.runT * 20) % 2));
@@ -689,10 +715,10 @@ function render() {
}
if (outMarker) drawRing(outMarker.x, outMarker.z, outMarker.out ? '#ff5050' : '#f8e048');
// court staff, then entities far→near, net between
drawStatic('x_ump', 7.1, COURT.netZ, 2.7);
if (boy && boy.z <= COURT.netZ) drawBoy(boy);
drawChar(O);
// far-side drawables in z order (far → near), then the net, then the near side
const farSide = [{ z: O.z, f: () => drawChar(O) }, { z: COURT.netZ - 0.01, f: () => drawStatic('x_ump', 7.1, COURT.netZ, 2.7) }];
if (boy && boy.z <= COURT.netZ) farSide.push({ z: boy.z, f: () => drawBoy(boy) });
farSide.sort((a, b) => a.z - b.z).forEach(d => d.f());
// net
const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ);
const nlt = proj(-COURT.netW / 2, COURT.netH, COURT.netZ), nrt = proj(COURT.netW / 2, COURT.netH, COURT.netZ);