diff --git a/assets/_sheet_boyrun.png b/assets/_sheet_boyrun.png new file mode 100644 index 0000000..1db5390 Binary files /dev/null and b/assets/_sheet_boyrun.png differ diff --git a/assets/x_boy_kneel.png b/assets/x_boy_kneel.png deleted file mode 100644 index 03c0a42..0000000 Binary files a/assets/x_boy_kneel.png and /dev/null differ diff --git a/assets/x_boy_stand.png b/assets/x_boy_stand.png deleted file mode 100644 index 48fd5d4..0000000 Binary files a/assets/x_boy_stand.png and /dev/null differ diff --git a/assets/x_boyrun1.png b/assets/x_boyrun1.png new file mode 100644 index 0000000..d175713 Binary files /dev/null and b/assets/x_boyrun1.png differ diff --git a/assets/x_boyrun2.png b/assets/x_boyrun2.png new file mode 100644 index 0000000..0bd7f87 Binary files /dev/null and b/assets/x_boyrun2.png differ diff --git a/game.js b/game.js index 041bdf5..5530da2 100644 --- a/game.js +++ b/game.js @@ -10,7 +10,7 @@ const TUNE = { gravity: 9.2, // slightly floaty vs 9.8 for readability bounceY: 0.58, bounceXZ: 0.78, playerSpeed: 5.4, aiSpeed: 5.0, - reach: 1.7, aiReach: 1.6, + 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 @@ -134,6 +134,18 @@ function noiseBuf(ac, len) { for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; return b; } +function swish(vol = 0.3) { // racket cutting air — the sound of a whiff + try { + const ac = audio(), t = ac.currentTime; + const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.16); + const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1600, t); + bp.frequency.exponentialRampToValueAtTime(3200, t + 0.12); bp.Q.value = 1.5; + const g = ac.createGain(); + g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.05); + g.gain.exponentialRampToValueAtTime(0.001, t + 0.16); + src.connect(bp).connect(g).connect(ac.destination); src.start(); + } catch (e) {} +} function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock try { const ac = audio(), t = ac.currentTime; @@ -180,7 +192,7 @@ function say(text, delay = 0) { // fallback when a recorded umpire line is missi const SPR = {}; for (const n of ['p_idle1','p_idle2','p_bounce','p_toss','p_smash','p_run1','p_run2','p_swing1','p_swing2', 'o_idle1','o_idle2','o_bounce','o_toss','o_smash','o_run1','o_run2','o_swing1','o_swing2', - 'x_ump','x_boy_kneel','x_boy_stand']) { + 'x_ump','x_boyrun1','x_boyrun2']) { const img = new Image(); img.onload = () => SPR[n] = img; img.src = 'assets/' + n + '.png'; @@ -194,16 +206,11 @@ 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, idleT = 0, outMarker = null; // flash = {text,x,y,t} -const BOYS = [ // ball boys crouch beside the net posts, fetch dead balls, run back - { hx: -6.2, hz: COURT.netZ - 0.8, x: -6.2, z: COURT.netZ - 0.8, state: 'idle', tx: 0, tz: 0, runT: 0 }, - { hx: 6.2, hz: COURT.netZ + 0.8, x: 6.2, z: COURT.netZ + 0.8, state: 'idle', tx: 0, tz: 0, runT: 0 }, -]; +let boy = null; // a ball boy sprints across the field, scoops the dead ball, exits the far side function sendBoy() { - const bx = ball.x, bz = ball.z; - if (Math.abs(bx) > 12 || bz < -4 || bz > 27) return; // ball left the field, let it go - const dist = b => Math.hypot(b.x - bx, b.z - bz); - const boy = dist(BOYS[0]) <= dist(BOYS[1]) ? BOYS[0] : BOYS[1]; - boy.state = 'fetch'; boy.tx = bx; boy.tz = bz; + if (ball.z < -3 || ball.z > 27) return; // ball left the field lengthwise, let it go + 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; const crowd = { cheer: 0, dots: [] }; @@ -239,6 +246,9 @@ function solveShot(x0, y0, z0, tx, tz, s) { function doHit(ch, quality, aim) { // aim: {ax: -1|0|1, az: -1|0|1} from held keys at contact (az -1 = deep, 1 = short/drop) const toFar = ch.side === 'P'; + // snap the ball to the racket at the contact frame so the hit visually connects + 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 tzBase = aim.az < 0 ? 3.0 : aim.az > 0 ? 8.6 : 5.4; // depth from target baseline @@ -456,7 +466,10 @@ function update(dt) { else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing } } - if (P.swingT >= 0) { P.swingT += dt; trySwing(P, false); if (P.swingT > 0.4) P.swingT = -1; } + if (P.swingT >= 0) { + P.swingT += dt; trySwing(P, false); // a contact resets swingT to -1 inside trySwing + if (P.swingT > TUNE.swingWindup + TUNE.swingWindow + 0.02) { P.swingT = -1; if (phase === 'rally') swish(); } + } if (P.pose === 'swing' || P.pose === 'serve') { P.poseT += dt; if (P.poseT > 0.38) P.pose = 'idle'; } // --- AI @@ -518,18 +531,15 @@ function update(dt) { } } - // ball boys: fetch the dead ball, then trot home - for (const b of BOYS) { - if (b.state === 'idle') continue; - b.runT += dt; - const tx = b.state === 'fetch' ? b.tx : b.hx, tz = b.state === 'fetch' ? b.tz : b.hz; - const dx = tx - b.x, dz = tz - b.z, d = Math.hypot(dx, dz); - if (d < 0.25) { - if (b.state === 'fetch' && !ball.active) ball.collected = true; // scoops it up - b.state = b.state === 'fetch' ? 'return' : 'idle'; - continue; + // ball boy: sprint across, scoop the ball in passing, despawn off the far side + if (boy) { + boy.x += boy.dir * 7.5 * dt; boy.runT += dt; + if (!ball.collected && !ball.active) { + const dz = ball.z - boy.z; + boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little + if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; } - b.x += dx / d * 6.5 * dt; b.z += dz / d * 6.5 * dt; + if (Math.abs(boy.x) > 13.5) boy = null; } if (outMarker && (outMarker.t -= dt) <= 0) outMarker = null; @@ -568,9 +578,15 @@ function drawStatic(name, x, z, hMeters, bob = 0) { ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h); } function drawBoy(b) { - const moving = b.state !== 'idle'; - drawStatic(moving ? 'x_boy_stand' : 'x_boy_kneel', b.x, b.z, moving ? 1.45 : 1.05, - moving ? Math.floor(b.runT * 16) % 2 : 0); + 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; + 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)); + if (b.dir < 0) ctx.scale(-1, 1); + ctx.drawImage(img, -w / 2, -h, w, h); + ctx.restore(); } function drawRing(x, z, color) { const p = proj(x, 0, z); @@ -669,7 +685,7 @@ function render() { // court staff, then entities far→near, net between drawStatic('x_ump', 7.1, COURT.netZ, 2.7); - for (const b of BOYS) if (b.z <= COURT.netZ) drawBoy(b); + if (boy && boy.z <= COURT.netZ) drawBoy(boy); drawChar(O); // net const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ); @@ -679,7 +695,7 @@ function render() { ctx.strokeStyle = '#eeeeee'; ctx.lineWidth = 1.5; 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); - for (const b of BOYS) if (b.z > COURT.netZ) drawBoy(b); + if (boy && boy.z > COURT.netZ) drawBoy(boy); // server idly bouncing the ball while waiting if (phase === 'pretoss') {