diff --git a/.claude/launch.json b/.claude/launch.json index c53a3a2..38f6d1d 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -3,9 +3,10 @@ "configurations": [ { "name": "splatter-tennis", - "runtimeExecutable": "python3", - "runtimeArgs": ["-m", "http.server", "8641"], - "port": 8641 + "runtimeExecutable": "sh", + "runtimeArgs": ["-c", "python3 -m http.server ${PORT:-8641}"], + "port": 8641, + "autoPort": true } ] } diff --git a/config.js b/config.js index 1b307f4..2ccb151 100644 --- a/config.js +++ b/config.js @@ -36,6 +36,10 @@ export const UMP_H = 3.0; // contact rules: outside this height band the ball can't be hit (below = it's past you) export const CONTACT = { minY: 0.24, maxY: 2.3 }; +// volleys (pre-bounce contact) get extra forgiveness: the ball passes at full speed and +// there's no bounce cue to time against, so reach and swing window widen by these factors +export const VOLLEY = { reach: 1.4, window: 1.8 }; + // how deep from the far/near baseline a player's interception zone extends export const ZONE_DEPTH = 9.5; diff --git a/gameplay.js b/gameplay.js index 1a53bb7..18df7bf 100644 --- a/gameplay.js +++ b/gameplay.js @@ -1,6 +1,6 @@ // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. -import { TUNE, COURT, CONTACT, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; +import { TUNE, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; import { audio, pock, swish, applause, voice } from './audio.js'; import { axisX, axisZ, takeFlickAim } from './input.js'; @@ -76,6 +76,7 @@ function doHit(ch, quality, aim) { 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; + G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return ball.pow = quality; // carried pace: zombies splatter only above zSplatQ if (ch.side === 'P') { // shot marker: where this will land — red means it's going out const ok = G.mode !== 'tennis' || (Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ); @@ -267,17 +268,20 @@ function intercept(side) { function trySwing(ch, isAI) { // Contact happens if the ball is inside reach while the swing window is open. - const reach = isAI ? TUNE.aiReach : TUNE.reach; + // Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue. + const volley = !isAI && ball.bounces === 0; + const reach = isAI ? TUNE.aiReach : TUNE.reach * (volley ? VOLLEY.reach : 1); + const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1); const t = ch.swingT; - if (t < TUNE.swingWindup || t > TUNE.swingWindup + TUNE.swingWindow) return; + if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return; if (!ball.active || G.phase !== 'rally') return; 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 > CONTACT.maxY) return; if (ball.y < CONTACT.minY) 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); + const center = TUNE.swingWindup + win / 2; + const off = Math.abs(t - center) / (win / 2); let q = Math.max(0.05, 1 - off); if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1; ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite @@ -361,7 +365,8 @@ export function update(dt) { } 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 (G.phase === 'rally') swish(); } + const win = TUNE.swingWindow * (ball.active && ball.bounces === 0 ? VOLLEY.window : 1); // match trySwing's volley window + if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); } } expirePose(P, dt);