diff --git a/assets/_sheet_boyrun.png b/assets/_sheet_boyrun.png index b9eddc4..506f3f0 100644 Binary files a/assets/_sheet_boyrun.png and b/assets/_sheet_boyrun.png differ diff --git a/assets/x_boyrun1.png b/assets/x_boyrun1.png index bc71548..5ce103d 100644 Binary files a/assets/x_boyrun1.png and b/assets/x_boyrun1.png differ diff --git a/assets/x_boyrun2.png b/assets/x_boyrun2.png index d4404fe..67cb41b 100644 Binary files a/assets/x_boyrun2.png and b/assets/x_boyrun2.png differ diff --git a/game.js b/game.js index b41c436..57a2e50 100644 --- a/game.js +++ b/game.js @@ -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);