Field data (miss log) showed the dominant unfair miss: the assist parks you on the landing spot, the ball spends the whole swing window below minY rising off the bounce at your feet, and becomes hittable one frame after the window closes. Root fix: the below-ankles rejection now only applies to a DROPPING ball — rising post-bounce balls can be scooped at any height (a half-volley). Feet-bounce taps go from ~0 to 19/30 winning frames; 30 simulated real points now log 50 hits, 0 whiffs. Also: swing-log snapshots record the same swing-timer value trySwing tests (removes the one-frame misclassification behind 'unclassified'), and tapping the scoreboard toggles an on-screen dump of the last 9 log entries so live situations can be screenshotted from the phone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
534 lines
25 KiB
JavaScript
534 lines
25 KiB
JavaScript
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
|
|
|
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';
|
|
import { updateZombies, spawnZombie, zombies } from './zombies.js';
|
|
|
|
// ------------------------------------------------------------- ballistics ----
|
|
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
|
|
export 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 * g * T * T) / T };
|
|
}
|
|
|
|
export function predictLanding() { // first-bounce point, closed form — exact, so the ring never jitters
|
|
const g = ball.g || TUNE.gravity;
|
|
const disc = ball.vy * ball.vy + 2 * g * ball.y;
|
|
if (disc < 0) return null;
|
|
const t = (ball.vy + Math.sqrt(disc)) / g; // time until y = 0
|
|
return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t };
|
|
}
|
|
|
|
// splatter aiming: your flick picks a lane (ax) and depth band (az); the shot locks onto
|
|
// the best-matching zombie anywhere on the court — including your own side of the net.
|
|
function zombieTarget(aim) {
|
|
if (!zombies.length) return null;
|
|
const none = !aim || (!aim.ax && !aim.az);
|
|
let best = null, bs = 1e9;
|
|
for (const zb of zombies) {
|
|
let s;
|
|
if (none) s = COURT.L - zb.z; // no direction: the most advanced one is the most urgent
|
|
else {
|
|
s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand
|
|
if (aim.az < 0) s += zb.z * 0.35; // flick up: prefer the far ones
|
|
if (aim.az > 0) s += (COURT.L - zb.z) * 0.35; // flick down: the close ones on your side
|
|
}
|
|
if (s < bs) { bs = s; best = zb; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function doHit(ch, quality, aim) {
|
|
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
|
|
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, tz;
|
|
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
|
|
const zt = G.mode !== 'tennis' && ch.side === 'P' ? zombieTarget(aim) : null;
|
|
if (zt) {
|
|
tx = zt.x + err() * 0.4;
|
|
const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s;
|
|
tz = Math.min(23, zt.z + zt.v * T0) + err() * 0.3; // lead the shambler's walk
|
|
} else {
|
|
tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide
|
|
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
|
|
tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
|
|
}
|
|
tx = Math.max(-5, Math.min(5, tx));
|
|
// 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 * 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, 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;
|
|
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);
|
|
G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
|
|
G.aiReactT = 0; // CPU hasn't read the shot yet
|
|
}
|
|
pock(0.9, 950);
|
|
if (quality > 0.999) { G.hitstop = 0.05; G.flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); }
|
|
ch.pose = 'swing'; ch.poseT = 0;
|
|
}
|
|
|
|
// ----------------------------------------------------------------- serve ----
|
|
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 = TOSS.y0;
|
|
ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity;
|
|
G.phase = 'toss';
|
|
}
|
|
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
|
|
|
|
function hitServe(sv, quality, aim = null) {
|
|
const toFar = sv.side === 'P';
|
|
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
|
let boxX, tzs;
|
|
const zt = G.mode !== 'tennis' ? zombieTarget(aim) : null;
|
|
if (zt) { // splatter serves auto-track the most advanced zombie
|
|
boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8;
|
|
const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s;
|
|
tzs = Math.min(23, zt.z + zt.v * T0);
|
|
} else {
|
|
// target: diagonal service box. Serving from deuce side → target box is across.
|
|
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;
|
|
tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
|
|
}
|
|
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;
|
|
ball.pow = quality;
|
|
if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 };
|
|
G.phase = 'rally'; pock(1, 1100);
|
|
sv.pose = 'serve'; sv.poseT = 0;
|
|
if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- scoring ----
|
|
function scoreKey() {
|
|
const N = ['love', 'fifteen', 'thirty', 'forty'];
|
|
const s = score.server === 'P' ? [score.pts[0], score.pts[1]] : [score.pts[1], score.pts[0]];
|
|
if (s[0] >= 3 && s[1] >= 3) {
|
|
if (s[0] === s[1]) return 'deuce';
|
|
return (s[0] > s[1]) === (score.server === 'P') ? 'advantage_player' : 'advantage_receiver';
|
|
}
|
|
if (s[0] === s[1]) return N[s[0]] + '_all';
|
|
return N[s[0]] + '_' + N[s[1]];
|
|
}
|
|
|
|
function cheer(seconds, vol) {
|
|
applause(seconds, vol);
|
|
crowd.cheer = Math.max(crowd.cheer, seconds);
|
|
}
|
|
|
|
function advancePoint(side) { // shared scoring math; returns whether a game was won
|
|
const i = side === 'P' ? 0 : 1, j = 1 - i;
|
|
score.pts[i]++;
|
|
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) {
|
|
score.games[i]++; score.pts = [0, 0];
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function pointTo(side, reason) {
|
|
if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it
|
|
const n = G.rallyNear;
|
|
logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`,
|
|
dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) });
|
|
}
|
|
const gameOver = advancePoint(side);
|
|
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
|
|
sendBoy();
|
|
if (reason) voice(reason);
|
|
if (gameOver) {
|
|
if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned) return startTransition();
|
|
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
|
|
cheer(2.8, 0.6);
|
|
score.server = other(score.server);
|
|
} else {
|
|
voice(scoreKey(), 0.7);
|
|
if (side === 'P') cheer(1.2, 0.3);
|
|
}
|
|
G.serveNum = 1;
|
|
}
|
|
|
|
// splatter-mode scoring: points land mid-flight without interrupting play
|
|
export function splatPoint(side) {
|
|
const gameOver = advancePoint(side);
|
|
if (gameOver) {
|
|
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5);
|
|
cheer(2.2, 0.5);
|
|
} else {
|
|
voice(scoreKey(), 0.45);
|
|
if (side === 'P') cheer(0.7, 0.25);
|
|
}
|
|
}
|
|
|
|
// hidden shortcut: tapping the umpire three times summons them early
|
|
export function forceZombies() {
|
|
if (G.mode !== 'tennis') return;
|
|
ball.active = false;
|
|
startTransition();
|
|
}
|
|
|
|
// -------------------------------------------------------------- transition ----
|
|
// You won your first game. The applause dies. The match... continues.
|
|
function startTransition() {
|
|
G.mode = 'transition'; G.transT = 0; G.tFlags = {}; G.everTransitioned = true;
|
|
G.overT = 9999; // hold the point flow; the timeline below releases it
|
|
applause(0.7, 0.5); // the clap that gets cut off
|
|
G.serveNum = 1;
|
|
}
|
|
|
|
function transition(dt) {
|
|
const t = (G.transT += dt);
|
|
const once = (key, at, fn) => { if (t >= at && !G.tFlags[key]) { G.tFlags[key] = true; fn(); } };
|
|
once('quiet', 1.8, () => voice('quiet_dark', 0, 0.82));
|
|
once('turn', 3.4, () => { spawnZombie(O.x, O.z, 'c'); G.hideO = true; }); // the opponent... turns
|
|
once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); });
|
|
once('seated', 5.4, () => voice('remain_seated', 0, 0.95));
|
|
once('go', 8.2, () => { G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); });
|
|
}
|
|
|
|
function fault() {
|
|
if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); }
|
|
else pointTo(other(score.server), 'double_fault');
|
|
}
|
|
|
|
export function inCourt(x, z) { return Math.abs(x) <= COURT.W / 2 + 0.02 && z >= -0.02 && z <= COURT.L + 0.02; }
|
|
export function inServeBox(x, z) {
|
|
const toFar = score.server === 'P';
|
|
const xOK = deuceSide() ? (toFar ? x <= 0.02 : x >= -0.02) : (toFar ? x >= -0.02 : x <= 0.02);
|
|
const zOK = toFar ? (z >= COURT.svcFar - 0.02 && z <= COURT.netZ) : (z >= COURT.netZ && z <= COURT.svcNear + 0.02);
|
|
return xOK && Math.abs(x) <= COURT.W / 2 + 0.02 && zOK;
|
|
}
|
|
|
|
function onBounce() {
|
|
ball.bounces++;
|
|
pock(0.35, 500);
|
|
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
|
|
if (ball.bounces === 1) {
|
|
ball.firstLand = { x: ball.x, z: ball.z };
|
|
const wasServe = G.wasServe; G.wasServe = false;
|
|
if (wasServe) {
|
|
if (!inServeBox(ball.x, ball.z)) { ball.active = false; fault(); }
|
|
return;
|
|
}
|
|
// must land on the side opposite the hitter
|
|
const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ;
|
|
if (!inCourt(ball.x, ball.z) || !okSide) {
|
|
ball.active = false; pointTo(other(ball.lastHit), 'out');
|
|
}
|
|
} else if (ball.bounces === 2) {
|
|
ball.active = false; pointTo(ball.lastHit, null);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------- movement ----
|
|
// Where should a player run to? Simulate the ball forward and take the first point in
|
|
// their half at hitting height that they can actually REACH IN TIME; otherwise chase the
|
|
// deepest hittable point. This is the "every ball is catchable, timing is the skill" contract.
|
|
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 = ball.g || TUNE.gravity, dt = 0.01;
|
|
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
|
|
const home = HOME[side];
|
|
let fallback = null, deepest = null;
|
|
for (let i = 0; i < 300; i++) {
|
|
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
|
|
if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; }
|
|
if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) {
|
|
const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) };
|
|
const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt;
|
|
if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time
|
|
if (reachable && !fallback) fallback = pt; // low but arrivable
|
|
deepest = pt; // last resort: run at the deepest hittable point
|
|
}
|
|
}
|
|
return fallback || deepest || home;
|
|
}
|
|
|
|
// ---------------------------------------------------------- miss diagnostics ----
|
|
// Every player swing in tennis mode gets logged: HIT with quality, or MISS with the
|
|
// gate that rejected it and the ball's closest approach. Balls that pass without a
|
|
// swing log as PASSED when the point ends. Inspect live via T.G.missLog; the latest
|
|
// miss reason is drawn on the HUD (TUNE.missHud).
|
|
const r2 = x => Math.round(x * 100) / 100;
|
|
|
|
function logSwing(entry) {
|
|
entry.at = r2(G.idleT);
|
|
G.missLog.push(entry);
|
|
if (G.missLog.length > 50) G.missLog.shift();
|
|
if (entry.result !== 'HIT') G.lastMiss = { text: entry.reason, at: G.idleT };
|
|
}
|
|
|
|
function logWhiff() {
|
|
const snaps = G.swingSnaps; G.swingSnaps = null;
|
|
if (G.mode !== 'tennis') return;
|
|
if (!snaps || !snaps.length) {
|
|
logSwing({ result: 'MISS', reason: !ball.active ? 'no live ball' : 'ball was yours — already hit' });
|
|
return;
|
|
}
|
|
const best = snaps.reduce((a, b) => (b.d < a.d ? b : a));
|
|
const inReach = snaps.filter(s => s.d <= s.r);
|
|
let reason;
|
|
if (!inReach.length) {
|
|
// never in reach during the swing: was the swing mistimed, or the ball line simply wide?
|
|
const tX = ball.active && ball.vz > 0.1 ? (P.z - ball.z) / ball.vz : -1; // time until ball reaches your depth
|
|
const lat = tX > 0 ? Math.abs(ball.x + ball.vx * tX - P.x) : null; // how wide it will pass
|
|
const nearD = G.rallyNear ? G.rallyNear.d : 1e9; // closest true pass so far this rally
|
|
if (lat !== null && lat > best.r) reason = `ball line ${r2(lat)}m wide of you — run, don't swing`;
|
|
else if (tX > 0) reason = `tap too early — ball still ${r2(best.d)}m out, arrives ${Math.round(tX * 1000)}ms after swing ended`;
|
|
else if (nearD > best.r) reason = `ball passed ${r2(nearD)}m wide of you — never reachable from where you stood`;
|
|
else reason = `tap too late — ball already gone (it came within ${r2(nearD)}m of you earlier)`;
|
|
} else {
|
|
const inWin = inReach.filter(s => s.t >= TUNE.swingWindup && s.t <= TUNE.swingWindup + s.w);
|
|
if (!inWin.length) {
|
|
const first = inReach[0], last = inReach[inReach.length - 1];
|
|
reason = last.t < TUNE.swingWindup
|
|
? `tap too late — ball already passing ${Math.round((TUNE.swingWindup - last.t) * 1000)}ms before swing opened`
|
|
: `tap too early — ball arrived ${Math.round((first.t - (TUNE.swingWindup + first.w)) * 1000)}ms after window closed`;
|
|
} else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`;
|
|
else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`;
|
|
else reason = 'unclassified — in reach, in window, in height band'; // should not happen
|
|
}
|
|
logSwing({ result: 'MISS', reason, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t),
|
|
ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z) });
|
|
}
|
|
|
|
function trySwing(ch, isAI) {
|
|
// Contact happens if the ball is inside reach while the swing window is open.
|
|
// 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 + 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 && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable
|
|
// quality: how centered in the window the contact lands
|
|
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
|
|
const flick = isAI ? null : takeFlickAim();
|
|
const aim = isAI
|
|
? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 }
|
|
: flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) };
|
|
if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (TUNE.aiQualityMax - TUNE.aiQualityMin));
|
|
if (!isAI && G.mode === 'tennis') {
|
|
G.swingSnaps = null; G.rallyNear = null;
|
|
logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 });
|
|
}
|
|
doHit(ch, q, aim);
|
|
ch.swingT = -1;
|
|
}
|
|
|
|
export function keyPressed(code) {
|
|
if (code !== 'Space') return;
|
|
audio(); // unlock on first gesture
|
|
if (G.phase === 'pretoss' && score.server === 'P') {
|
|
if (G.mode === 'splatter') {
|
|
const flick = takeFlickAim();
|
|
if (flick) { // quick serve: no toss ritual, modest pace, aimed by the flick
|
|
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
|
|
ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity;
|
|
hitServe(P, 0.62, flick);
|
|
return;
|
|
}
|
|
}
|
|
tossBall(P); return;
|
|
}
|
|
if (G.phase === 'toss' && score.server === 'P') {
|
|
// serve timing: distance from apex = quality
|
|
const dy = Math.abs(ball.y - serveApexY());
|
|
if (ball.y < 2.0) return; // too low, let it drop (re-toss)
|
|
let q = Math.max(0.05, 1 - dy / 0.9);
|
|
if (dy < 0.12) q = 1;
|
|
G.wasServe = true;
|
|
hitServe(P, q);
|
|
return;
|
|
}
|
|
if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; }
|
|
}
|
|
|
|
function expirePose(ch, dt) {
|
|
if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- update ----
|
|
export function update(dt) {
|
|
if (G.hitstop > 0) { G.hitstop -= dt; return; }
|
|
if (G.mode === 'eaten') { // the match is over in the truest sense
|
|
G.idleT += dt; G.eatenT += dt;
|
|
updateZombies(dt);
|
|
return;
|
|
}
|
|
if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; }
|
|
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
|
G.idleT += dt;
|
|
const w = BOUNCE_IDLE.tempo;
|
|
if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce
|
|
if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
|
|
|
|
// --- player movement (near half only, generous margins)
|
|
if (G.phase === 'rally') {
|
|
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes
|
|
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8);
|
|
if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z };
|
|
}
|
|
const slow = P.swingT >= 0 ? 0.35 : 1;
|
|
const mx = axisX(), mz = axisZ();
|
|
P.x += mx * TUNE.playerSpeed * slow * dt;
|
|
P.z += mz * TUNE.playerSpeed * slow * dt;
|
|
let assist = 0;
|
|
if (!mx && !mz && TUNE.autoAssist > 0 && ball.active) {
|
|
const chasing = ball.lastHit === 'O';
|
|
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
|
|
const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55);
|
|
const tgt = chasing ? intercept('P') : HOME.P; // 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.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;
|
|
}
|
|
}
|
|
P.x = Math.max(-P_BOUNDS.x, Math.min(P_BOUNDS.x, P.x));
|
|
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
|
|
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) 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;
|
|
if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame)
|
|
const volley = ball.bounces === 0;
|
|
(G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y,
|
|
bz: ball.z, bounces: ball.bounces,
|
|
r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) });
|
|
}
|
|
trySwing(P, false); // a contact resets swingT to -1 inside trySwing
|
|
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(); logWhiff(); } // log even if the point ended mid-swing
|
|
}
|
|
expirePose(P, dt);
|
|
|
|
// --- zombies + transition timeline
|
|
if (G.mode !== 'tennis') updateZombies(dt);
|
|
if (G.mode === 'transition') transition(dt);
|
|
|
|
// --- AI (the opponent only exists in tennis mode)
|
|
if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') {
|
|
G.aiServeT -= dt;
|
|
if (G.aiServeT <= 0) tossBall(O);
|
|
} else if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'toss') {
|
|
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); }
|
|
}
|
|
if (G.mode === 'tennis' && G.phase === 'rally') {
|
|
G.aiReactT += dt;
|
|
const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction;
|
|
const tgt = incoming ? intercept('O') : HOME.O;
|
|
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(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';
|
|
G.aiSwingCool -= dt;
|
|
if (incoming && G.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 < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0;
|
|
if (near) {
|
|
if (Math.random() < TUNE.aiFlub) G.aiSwingCool = 0.5; // whiff: hesitates and misses
|
|
else {
|
|
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
|
|
trySwing(O, true); O.swingT = -1;
|
|
G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
expirePose(O, dt);
|
|
|
|
// --- ball physics
|
|
if (ball.active) {
|
|
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
|
|
if (G.phase === 'rally' && (oldZ - COURT.netZ) * (ball.z - COURT.netZ) < 0 && Math.abs(ball.x) < COURT.netW / 2) {
|
|
const tCross = (COURT.netZ - oldZ) / (ball.z - oldZ);
|
|
const yAt = ball.y - ball.vy * dt * (1 - tCross); // approx height at crossing
|
|
if (yAt < COURT.netH) {
|
|
ball.z = COURT.netZ + (oldZ < COURT.netZ ? -0.05 : 0.05);
|
|
ball.vz *= -0.08; ball.vx *= 0.3; ball.vy = Math.min(ball.vy, 0);
|
|
pock(0.4, 300);
|
|
}
|
|
}
|
|
if (ball.y <= 0 && ball.vy < 0) {
|
|
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
|
|
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
|
|
onBounce();
|
|
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
|
ball.active = false;
|
|
if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming
|
|
G.phase = 'over'; G.overT = 0.25; sendBoy();
|
|
if (Math.random() < 0.12) voice('new_balls', 0.3);
|
|
} else if (ball.bounces === 1) pointTo(ball.lastHit, null);
|
|
}
|
|
}
|
|
if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss
|
|
ball.active = false; G.phase = 'pretoss';
|
|
if (score.server === 'O') G.aiServeT = 0.8;
|
|
}
|
|
}
|
|
|
|
// ball boy: sprint across, scoop the ball in passing, despawn off the far side
|
|
const boy = G.boy;
|
|
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;
|
|
}
|
|
if (Math.abs(boy.x) > 13.5) G.boy = null;
|
|
}
|
|
if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null;
|
|
|
|
if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) setupPoint(); }
|
|
}
|