diff --git a/assets/_sheet_opp.png b/assets/_sheet_opp.png deleted file mode 100644 index 5189875..0000000 Binary files a/assets/_sheet_opp.png and /dev/null differ diff --git a/assets/_sheet_opp3.png b/assets/_sheet_opp3.png new file mode 100644 index 0000000..a9e1743 Binary files /dev/null and b/assets/_sheet_opp3.png differ diff --git a/assets/_sheet_player.png b/assets/_sheet_player.png deleted file mode 100644 index 1c4a09a..0000000 Binary files a/assets/_sheet_player.png and /dev/null differ diff --git a/assets/_sheet_player3.png b/assets/_sheet_player3.png new file mode 100644 index 0000000..06abf81 Binary files /dev/null and b/assets/_sheet_player3.png differ diff --git a/assets/o_bounce.png b/assets/o_bounce.png index b111aba..584b125 100644 Binary files a/assets/o_bounce.png and b/assets/o_bounce.png differ diff --git a/assets/o_idle1.png b/assets/o_idle1.png index a809e1f..83e73f7 100644 Binary files a/assets/o_idle1.png and b/assets/o_idle1.png differ diff --git a/assets/o_idle2.png b/assets/o_idle2.png index 788d00e..474175d 100644 Binary files a/assets/o_idle2.png and b/assets/o_idle2.png differ diff --git a/assets/o_run1.png b/assets/o_run1.png index c66d291..7cb99b5 100644 Binary files a/assets/o_run1.png and b/assets/o_run1.png differ diff --git a/assets/o_run2.png b/assets/o_run2.png index 1e605d5..5399f33 100644 Binary files a/assets/o_run2.png and b/assets/o_run2.png differ diff --git a/assets/o_smash.png b/assets/o_smash.png index aa44f43..6074b1c 100644 Binary files a/assets/o_smash.png and b/assets/o_smash.png differ diff --git a/assets/o_swing1.png b/assets/o_swing1.png index 86b5a68..d93a6a9 100644 Binary files a/assets/o_swing1.png and b/assets/o_swing1.png differ diff --git a/assets/o_swing2.png b/assets/o_swing2.png index 1cd98a6..adea653 100644 Binary files a/assets/o_swing2.png and b/assets/o_swing2.png differ diff --git a/assets/o_toss.png b/assets/o_toss.png index c89b074..35001a8 100644 Binary files a/assets/o_toss.png and b/assets/o_toss.png differ diff --git a/assets/p_bounce.png b/assets/p_bounce.png index 2b1579f..dcc4167 100644 Binary files a/assets/p_bounce.png and b/assets/p_bounce.png differ diff --git a/assets/p_idle1.png b/assets/p_idle1.png index 9145939..1dd8428 100644 Binary files a/assets/p_idle1.png and b/assets/p_idle1.png differ diff --git a/assets/p_idle2.png b/assets/p_idle2.png index d9b466b..f968412 100644 Binary files a/assets/p_idle2.png and b/assets/p_idle2.png differ diff --git a/assets/p_run1.png b/assets/p_run1.png index 9262021..dca236f 100644 Binary files a/assets/p_run1.png and b/assets/p_run1.png differ diff --git a/assets/p_run2.png b/assets/p_run2.png index 1798b9a..6d912ef 100644 Binary files a/assets/p_run2.png and b/assets/p_run2.png differ diff --git a/assets/p_smash.png b/assets/p_smash.png index 45e3498..6ae4a58 100644 Binary files a/assets/p_smash.png and b/assets/p_smash.png differ diff --git a/assets/p_swing1.png b/assets/p_swing1.png index 9cfc2c3..61e818c 100644 Binary files a/assets/p_swing1.png and b/assets/p_swing1.png differ diff --git a/assets/p_swing2.png b/assets/p_swing2.png index 36a3d68..0361941 100644 Binary files a/assets/p_swing2.png and b/assets/p_swing2.png differ diff --git a/assets/p_toss.png b/assets/p_toss.png index 34d4fdd..94b8573 100644 Binary files a/assets/p_toss.png and b/assets/p_toss.png differ diff --git a/assets/x_boy_kneel.png b/assets/x_boy_kneel.png index 84f25f5..03c0a42 100644 Binary files a/assets/x_boy_kneel.png and b/assets/x_boy_kneel.png differ diff --git a/assets/x_boy_stand.png b/assets/x_boy_stand.png index d6b572c..48fd5d4 100644 Binary files a/assets/x_boy_stand.png and b/assets/x_boy_stand.png differ diff --git a/assets/x_ump.png b/assets/x_ump.png index ab9a6b4..b66b572 100644 Binary files a/assets/x_ump.png and b/assets/x_ump.png differ diff --git a/game.js b/game.js index 3ab31a4..041bdf5 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.5, aiReach: 1.6, + reach: 1.7, aiReach: 1.6, 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 @@ -28,13 +28,15 @@ COURT.svcNear = COURT.netZ + 6.4; // 18.285 // ---------------------------------------------------------------- canvas ---- const cv = document.getElementById('c'), ctx = cv.getContext('2d'); -let W = cv.width, H = cv.height; +const RES = 2; // physical pixels per logical pixel — sprites keep 2x detail +let W = 480, H = 270; function fitViewport() { // landscape 480x270, portrait 270x480 with a longer lens const portrait = innerHeight > innerWidth; const [w, h] = portrait ? [270, 480] : [480, 270]; - if (cv.width !== w) { - cv.width = W = w; cv.height = H = h; + if (cv.width !== w * RES) { + W = w; H = h; + cv.width = w * RES; cv.height = h * RES; TUNE.camF = portrait ? 400 : 280; TUNE.horizon = portrait ? 90 : 40; buildCrowd(); @@ -191,7 +193,18 @@ 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, idleT = 0; // flash = {text,x,y,t} +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 }, +]; +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; +} let aiServeT = 0, aiSwingCool = 0; const crowd = { cheer: 0, dots: [] }; function buildCrowd() { @@ -244,6 +257,10 @@ function doHit(ch, quality, aim) { } 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 }; + } pock(0.9, 950); if (quality > 0.999) { hitstop = 0.05; flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); } ch.pose = 'swing'; ch.poseT = 0; @@ -251,7 +268,7 @@ function doHit(ch, quality, aim) { // ----------------------------------------------------------------- serve ---- function tossBall(sv) { - ball.active = true; ball.bounces = 0; ball.firstLand = null; + 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; phase = 'toss'; @@ -263,9 +280,11 @@ function hitServe(sv, quality) { const boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality); const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6; const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; - const v = solveShot(ball.x, ball.y, ball.z, boxX, boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5, s); + const tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5; + const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s); 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 }; phase = 'rally'; pock(1, 1100); sv.pose = 'serve'; sv.poseT = 0; if (quality > 0.999) { flash = { text: 'PERFECT', t: 0.7 }; hitstop = 0.05; } @@ -290,6 +309,7 @@ function pointTo(side, reason) { score.games[i]++; score.pts = [0, 0]; gameOver = true; } phase = 'over'; overT = gameOver ? 3.2 : 2.2; + sendBoy(); if (reason) voice(reason); if (gameOver) { voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7); @@ -302,7 +322,7 @@ function pointTo(side, reason) { serveNum = 1; } function fault() { - if (serveNum === 1) { serveNum = 2; voice('fault'); phase = 'over'; overT = 1.4; } + if (serveNum === 1) { serveNum = 2; voice('fault'); phase = 'over'; overT = 1.4; sendBoy(); } else pointTo(other(score.server), 'double_fault'); } const other = s => s === 'P' ? 'O' : 'P'; @@ -421,18 +441,19 @@ function update(dt) { const rate = TUNE.playerSpeed * TUNE.autoAssist * slow * dt * (chasing ? 1 : 0.55); const tgt = chasing ? intercept('P') : { x: 0, z: 22.8 }; // run to the ball, then recover home const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz); - if (d > 0.05) { + if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it P.x += dx / d * Math.min(rate, d); P.z += dz / d * Math.min(rate, d); - assist = dx || 0.01; + assist = dx; } } P.x = Math.max(-6.5, Math.min(6.5, P.x)); P.z = Math.max(COURT.netZ + 1.2, Math.min(26, P.z)); - const moving = mx || mz || Math.abs(assist) > 0.005; + const moving = mx || mz || assist !== 0; P.runT += moving ? dt : 0; if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle'; - if (mx || assist) P.facing = (mx || assist) > 0 ? 1 : -1; + if (mx) P.facing = mx > 0 ? 1 : -1; + 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; } @@ -453,7 +474,7 @@ function update(dt) { O.x += dx / d * Math.min(TUNE.aiSpeed * dt, d); O.z += dz / d * Math.min(TUNE.aiSpeed * dt, d); O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt; - if (dx) O.facing = dx > 0 ? 1 : -1; + 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) { @@ -497,9 +518,34 @@ 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; + } + b.x += dx / d * 6.5 * dt; b.z += dz / d * 6.5 * dt; + } + if (outMarker && (outMarker.t -= dt) <= 0) outMarker = null; + if (phase === 'over') { overT -= dt; if (overT <= 0) setupPoint(); } } +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; + 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 }; + } + return null; +} + // ---------------------------------------------------------------- render ---- function courtXZ(x, z) { return proj(x, 0, z); } function line(x1, z1, x2, z2, w = 1) { @@ -513,13 +559,23 @@ function quadXZ(x1, z1, x2, z2, color) { // filled court-plane rect (screen trap ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.fill(); } -function drawStatic(name, x, z, hMeters) { +function drawStatic(name, x, z, hMeters, bob = 0) { const img = SPR[name]; if (!img) return; const p = proj(x, 0, z); const h = hMeters * 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.4, Math.max(1, h * 0.04), 0, 0, 7); ctx.fill(); - ctx.drawImage(img, p.x - w / 2, p.y - h, w, h); + 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); +} +function drawRing(x, z, color) { + const p = proj(x, 0, z); + ctx.strokeStyle = color; ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.ellipse(p.x, p.y, p.s * 0.3, p.s * 0.12, 0, 0, 7); ctx.stroke(); } function drawChar(ch) { @@ -542,7 +598,7 @@ function drawChar(ch) { ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(p.x, p.y, hpx * 0.22, hpx * 0.06, 0, 0, 7); ctx.fill(); if (img) { - const k = hpx / 56; // sprites share one scale; idle pose is 56px tall + const k = hpx / 112; // sprites share one scale; idle pose is 112px tall const w = img.width * k, h = img.height * k; ctx.save(); ctx.translate(p.x, p.y); @@ -570,6 +626,7 @@ function drawFallbackChar(ch, p, h) { // ponytail: replaced by generated sprites } function render() { + ctx.setTransform(RES, 0, 0, RES, 0, 0); // logical W x H coordinates at RES density // stands: tiered crowd from top of screen down to just above the far court const standsBottom = proj(0, 0, 0).y - 12; ctx.fillStyle = '#232e42'; ctx.fillRect(0, 0, W, standsBottom); @@ -603,10 +660,16 @@ function render() { line(0, COURT.svcFar, 0, COURT.svcNear, 1); line(0, 0, 0, 0.35, 1); line(0, COURT.L - 0.35, 0, COURT.L, 1); // center marks + // landing rings: white = incoming ball, yellow/red = your shot (red lands out) + if (phase === 'rally' && ball.active && ball.bounces === 0 && ball.lastHit === 'O') { + const l = predictLanding(); + if (l) drawRing(l.x, l.z, 'rgba(255,255,255,0.85)'); + } + if (outMarker) drawRing(outMarker.x, outMarker.z, outMarker.out ? '#ff5050' : '#f8e048'); + // court staff, then entities far→near, net between - drawStatic('x_boy_kneel', -4.2, -1.3, 1.1); - drawStatic('x_boy_stand', 4.2, -1.3, 1.45); drawStatic('x_ump', 7.1, COURT.netZ, 2.7); + for (const b of BOYS) if (b.z <= COURT.netZ) drawBoy(b); drawChar(O); // net const nl = proj(-COURT.netW / 2, 0, COURT.netZ), nr = proj(COURT.netW / 2, 0, COURT.netZ); @@ -616,6 +679,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); // server idly bouncing the ball while waiting if (phase === 'pretoss') { @@ -628,7 +692,7 @@ function render() { 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') { + if ((ball.active || phase === 'over') && !ball.collected) { const sh = proj(ball.x, 0, ball.z); ctx.fillStyle = 'rgba(0,0,0,0.3)'; ctx.beginPath(); ctx.ellipse(sh.x, sh.y, Math.max(1.5, sh.s * 0.1), Math.max(1, sh.s * 0.04), 0, 0, 7); ctx.fill();