Tins now accumulate on a courtside rack (cap 3) instead of replacing each other; a PLAIN slot banks specials for later, and GUN is a charge you pop for an auto-fire burst. Tap the rack or press 1-5 to choose what your serve throws. A fresh delivery auto-equips but never overrides a deliberate PLAIN, and the 1:1 sudden-death duel forces plain balls. HUD gets real art: generated pixel-art weapon icons, an ornate Wimbledon scoreboard PNG backing the live text (no more code-drawn boxes on top), and a bundled Silkscreen pixel font replacing the system serif. Rack tiles get padding, count-over-label alignment, and an infinity marker on the ball. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
822 lines
42 KiB
JavaScript
822 lines
42 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, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
|
|
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
|
|
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
|
import { updateZombies, spawnZombie, spawnRacketZombie, sweepCourt, zombies, explode, meleeSwipe, extras, parts } 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 FREE-AIM: the flick vector maps to a landing point from where you stand — a short
|
|
// flick drops it close, a long one reaches the far baseline. A zombie within zSnap of that
|
|
// point magnets the shot (with walk-lead); otherwise the ball lands exactly where you flicked.
|
|
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
|
|
if (!aim.mag) return { zt: zombieTarget(null) }; // pure hold, no direction yet: auto-target the most advanced
|
|
const R = Math.min(1, Math.max(0.12, aim.mag / 160)) * 22; // px flick length -> meters of reach
|
|
const tx = Math.max(-6.5, Math.min(6.5, ch.x + aim.fx * R));
|
|
const tz = Math.max(1, Math.min(24.5, ch.z + aim.fy * R)); // screen up = up-court (-z)
|
|
let zt = null, bd = TUNE.zSnap;
|
|
for (const zb of zombies) {
|
|
const d = Math.hypot(zb.x - tx, zb.z - tz);
|
|
if (d < bd) { bd = d; zt = zb; }
|
|
}
|
|
return { tx, tz, zt };
|
|
}
|
|
|
|
// splatter tap/lane aiming (no flick): 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 ft = G.mode !== 'tennis' && ch.side === 'P'
|
|
? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null;
|
|
if (ft && ft.zt) {
|
|
const zt = ft.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 if (ft && ft.tx !== undefined) {
|
|
tx = ft.tx + err() * 0.4; tz = ft.tz + err() * 0.3; // no magnet: the ball goes where you flicked
|
|
} 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
|
|
ball.kills = 0; // fresh shot: multi-kill streak restarts
|
|
ball.bomb = false; ball.flame = false; // only a consuming serve arms these; any other hit clears them
|
|
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
|
|
}
|
|
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side
|
|
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 ft = G.mode !== 'tennis'
|
|
? (aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }) : null;
|
|
if (ft && ft.zt) { // magnet hit, or (tap) the most advanced zombie
|
|
const zt = ft.zt;
|
|
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 if (ft && ft.tx !== undefined) { // free-aim, no magnet: serve to the flicked spot
|
|
boxX = ft.tx; tzs = ft.tz;
|
|
} 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;
|
|
ball.kills = 0; // fresh serve: multi-kill streak restarts
|
|
// the SELECTED tin arms the serve; slot 0 (PLAIN) throws a normal ball and banks your
|
|
// specials for later. Placement still follows the flick — only WHICH ball flies is the
|
|
// rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve.
|
|
const wsel = G.mode === 'splatter' ? G.weapon : 0;
|
|
ball.bomb = wsel === 1 && G.bombs > 0;
|
|
if (ball.bomb) G.bombs--;
|
|
ball.flame = wsel === 3 && G.flames > 0;
|
|
if (ball.flame) G.flames--;
|
|
if (wsel === 2 && G.triples > 0) { // one serve, three balls: clones fanned ±6°
|
|
G.triples--;
|
|
for (const deg of [-6, 6]) {
|
|
const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r);
|
|
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5,
|
|
vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c });
|
|
}
|
|
}
|
|
if (wsel > 0 && slotStock(wsel) <= 0) G.weapon = 0; // that was the last tin — hand back to plain balls
|
|
if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !inServeBox(boxX, tzs), t: 1.3 };
|
|
G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side
|
|
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]];
|
|
}
|
|
|
|
export function cheer(seconds, vol) {
|
|
sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies)
|
|
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 (side === 'O') aww(0.8); // you dropped the point — the crowd sighs
|
|
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.
|
|
// quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point.
|
|
// The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde,
|
|
// odd games end in a changeover breather.
|
|
export function splatPoint(side, quiet = false) {
|
|
if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter
|
|
if (side === 'P') G.killCount = 0;
|
|
const gameOver = advancePoint(side);
|
|
if (gameOver) {
|
|
G.runGames++;
|
|
if (side === 'O') G.shameGame = true; // they took a game — the next new-balls delivery is withheld
|
|
// the new-balls rule arms you: deliveries land at the changeover that follows the scheduled game
|
|
if (G.runGames === TUNE.ballChangeFirst ||
|
|
(G.runGames > TUNE.ballChangeFirst && (G.runGames - TUNE.ballChangeFirst) % TUNE.ballChangeEvery === 0))
|
|
G.tinDue = true;
|
|
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
|
|
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
|
|
sweepCourt(); // the wave is OVER: survivors collapse where they stand — a game ends visibly
|
|
ball.active = false; // the beat owns the court
|
|
G.pendingGame = true; // 'GAME N' announces the next wave when play resumes
|
|
const i = side === 'P' ? 0 : 1;
|
|
if (score.games[i] >= 6) { // set to the winner — and the court answers with a horde
|
|
G.sets[i]++; score.games = [0, 0];
|
|
startHorde();
|
|
} else if ((score.games[0] + score.games[1]) % 2 === 1) startChangeover(); // real rule: after odd games
|
|
else { G.phase = 'over'; G.overT = TUNE.gameBeatT; } // even games get a real beat too, not a 0.1s blink
|
|
if (side === 'P' && !zombies.some(z => z.variant === 'r'))
|
|
spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it)
|
|
} else if (!quiet || G.idleT - G.lastScoreT > 2) {
|
|
voice(scoreKey(), 0.45); G.lastScoreT = G.idleT;
|
|
if (side === 'P' && !quiet) cheer(0.7, 0.25);
|
|
}
|
|
}
|
|
|
|
// changeover (after odd games, as the rules demand): a forced breather — no spawns,
|
|
// no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe.
|
|
function startChangeover() {
|
|
G.changeover = true;
|
|
G.phase = 'over'; G.overT = TUNE.changeoverT;
|
|
sendBoy();
|
|
if (G.tinDue) { // the boys arm you — unless they took a game since the last delivery
|
|
G.tinDue = false;
|
|
if (G.shameGame) G.flash = { text: 'NO NEW BALLS', t: 1.6 }; // the umpire withholds
|
|
else deliverTin();
|
|
G.shameGame = false; // the slate is clean either way
|
|
} else G.flash = { text: 'CHANGEOVER', t: 1.4 };
|
|
}
|
|
|
|
// ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is
|
|
// infinite plain balls. selectWeapon is the tap/key target — an empty special can't be picked.
|
|
export const SLOTS = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN'];
|
|
export const slotStock = i => [Infinity, G.bombs, G.triples, G.flames, G.guns][i];
|
|
export function selectWeapon(i) {
|
|
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
|
|
if (i > 0 && slotStock(i) <= 0) return; // dud: no tins of that kind on the rack
|
|
G.weapon = i; pock(0.3, 1100);
|
|
}
|
|
|
|
// the weapon ladder: each delivery opens the next tin in the cycle and ADDS it to the rack
|
|
// (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde
|
|
// instead of spending it now. G.weapon picks which tin the next serve throws.
|
|
export function deliverTin() {
|
|
const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k);
|
|
if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax);
|
|
else if (w === 1) G.triples = add(G.triples + 3);
|
|
else if (w === 2) G.flames = add(G.flames + 3);
|
|
else G.guns = add(G.guns + 1); // one machine-gun charge — a burst you pop when you choose, not on arrival
|
|
if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking)
|
|
sendBoy(); voice('new_balls', 0.6);
|
|
G.flash = { text: 'NEW BALLS: ' + SLOTS[w + 1], t: 1.6 };
|
|
}
|
|
|
|
// ---- the interlude: order is restored, one sudden-death point vs the racket zombie.
|
|
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching.
|
|
export function resolveInterlude(side) {
|
|
if (!G.interlude) return;
|
|
G.interlude = false;
|
|
zombieDuck(false); // duel over — bring the music back up
|
|
ball.active = false; G.phase = 'over'; G.overT = 2.2;
|
|
if (side === 'P') {
|
|
cheer(3, 0.7);
|
|
deliverTin(); // win the point, win the next tin — on the spot
|
|
} else {
|
|
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
|
|
score.games[1]++; G.runGames++; G.shameGame = true; // it goes on the scoreboard
|
|
if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
|
|
G.flash = { text: 'GAME. THEM.', t: 1.6 };
|
|
for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge
|
|
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b');
|
|
}
|
|
}
|
|
|
|
// retire the splatter ball; a running changeover's long beat must not be clobbered short
|
|
export function spentBall() {
|
|
ball.active = false;
|
|
G.phase = 'over';
|
|
if (!G.changeover) G.overT = TUNE.splatterOverT;
|
|
}
|
|
|
|
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
|
|
function startHorde() {
|
|
if (G.horde) return;
|
|
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
|
|
G.pendingGame = false; // the horde IS the announcement
|
|
G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 };
|
|
ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P
|
|
P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose
|
|
voice('remain_seated', 0.3);
|
|
G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
|
|
}
|
|
|
|
// hidden shortcut: tapping the umpire three times summons the horror early
|
|
export function forceZombies() {
|
|
if (G.mode === 'splatter') { startHorde(); return; } // in splatter the cheat forces a horde wave
|
|
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); zombieMusic(); });
|
|
}
|
|
|
|
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 === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up
|
|
explode(ball.x, ball.z); ball.bomb = false;
|
|
spentBall(); return;
|
|
}
|
|
if (G.mode === 'splatter' && G.interlude) { // sudden death: the lines are LIVE again
|
|
if (ball.bounces === 1) {
|
|
const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ;
|
|
if (!inCourt(ball.x, ball.z) || !okSide) { ball.active = false; resolveInterlude(other(ball.lastHit)); voice('out'); }
|
|
} else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); }
|
|
return;
|
|
}
|
|
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, qCap = TUNE.aiQualityMax) {
|
|
// 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() * (qCap - 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;
|
|
}
|
|
|
|
// racket zombie (splatter mini-boss): a slimmed tennis CPU that holds the far court and returns
|
|
// your SOFT shots as live balls. Splat-quality balls it won't rally — those wound its HP instead.
|
|
function racketAI(rz, dt) {
|
|
expirePose(rz, dt);
|
|
rz.swingCool -= dt;
|
|
const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0
|
|
&& (G.interlude || (ball.pow || 0) < TUNE.zSplatQ); // in the duel it plays EVERY ball — it's tennis now
|
|
if (!incoming) { if (rz.pose === 'run') rz.pose = 'idle'; return; }
|
|
const tz = Math.max(1.5, Math.min(COURT.svcFar, ball.z)); // chase laterally, hold deep on the far court
|
|
const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
|
|
if (d > 0.15) {
|
|
const step = Math.min(TUNE.racketSpeed * dt, d);
|
|
rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt;
|
|
if (rz.pose !== 'swing') rz.pose = 'run';
|
|
if (Math.abs(dx) > 0.2) rz.facing = dx > 0 ? 1 : -1;
|
|
} else if (rz.pose === 'run') rz.pose = 'idle';
|
|
if (rz.swingCool <= 0 && rz.swingT < 0
|
|
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach
|
|
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY) {
|
|
if (Math.random() < TUNE.racketFlub) rz.swingCool = 0.5; // whiff — it hesitates
|
|
else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; }
|
|
}
|
|
}
|
|
|
|
// GUN in the rack isn't a ball — committing a charge starts an mgT auto-fire burst (the same
|
|
// mode a horde uses). With GUN selected, any serve gesture spends one charge instead of serving.
|
|
function tryGunBurst() {
|
|
if (G.mode !== 'splatter' || G.weapon !== 4 || G.guns <= 0) return false;
|
|
G.guns--; if (G.guns <= 0) G.weapon = 0; // spent the last tin — back to plain balls
|
|
G.mgT = TUNE.mgPickupLen; G.mgCool = 0;
|
|
ball.active = false; G.phase = 'rally';
|
|
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
|
|
G.flash = { text: 'GUN', t: 1.0 };
|
|
return true;
|
|
}
|
|
|
|
// release of the two-stage aim: an instant serve to the reticle (quality fixed — placement
|
|
// is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling)
|
|
export function dragServe(aim) {
|
|
if (G.mode !== 'splatter' || G.phase !== 'pretoss' || score.server !== 'P') return;
|
|
if (G.horde || G.mgT > 0 || G.changeover || (G.interlude && G.interlude.introT > 0)) return;
|
|
if (tryGunBurst()) return;
|
|
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, aim);
|
|
}
|
|
|
|
export function keyPressed(code) {
|
|
if (code !== 'Space') return;
|
|
audio(); // unlock on first gesture
|
|
if (G.mode === 'splatter') meleeSwipe(P.x, P.z, TUNE.meleeRange); // a swish cleaves adjacent shamblers
|
|
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
|
|
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
|
|
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
|
|
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
|
|
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
|
|
if (G.phase === 'pretoss' && score.server === 'P') {
|
|
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
|
|
if (G.mode === 'splatter') {
|
|
const flick = takeFlickAim();
|
|
if (flick) { // flick = a quick aimed serve; a plain tap still does the full toss ritual
|
|
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 — a real serve, not a free tap
|
|
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;
|
|
// a flick at the apex (or held arrows) makes this a DELIBERATE serve: aimed + consumes a special
|
|
const flick = G.mode === 'splatter' ? takeFlickAim() : null;
|
|
const kb = { ax: axisX(), az: axisZ() };
|
|
hitServe(P, q, flick || (kb.ax || kb.az ? kb : null));
|
|
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; }
|
|
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
|
if (G.boomFlash > 0) G.boomFlash -= dt;
|
|
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 };
|
|
}
|
|
// auto-fire steering is ABSOLUTE: the finger x IS the player x — zero lag, and clamped
|
|
// to what the camera can see (portrait's world bounds are wider than its viewport)
|
|
const mgMode = G.horde || G.mgT > 0;
|
|
const xVis = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
|
|
let mgSteer = false;
|
|
if (mgMode && touchPX() !== null) {
|
|
mgSteer = true;
|
|
const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis));
|
|
if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; P.runT += dt; P.pose = 'run'; }
|
|
else P.pose = 'idle';
|
|
P.x = tx;
|
|
}
|
|
const slow = P.swingT >= 0 ? 0.35 : 1;
|
|
const mx = mgSteer ? 0 : axisX(), mz = mgSteer ? 0 : 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 && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist
|
|
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;
|
|
}
|
|
}
|
|
const xLim = mgMode ? xVis : P_BOUNDS.x; // auto-fire keeps even keyboard players on screen
|
|
P.x = Math.max(-xLim, Math.min(xLim, 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 (!mgSteer && 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);
|
|
|
|
// --- interlude trigger: the court is clear, an opponent holds the far court, you're about
|
|
// to serve — the umpire recognizes a legal match and restores order. One point. Sudden death.
|
|
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0)
|
|
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) {
|
|
zombies[0].dueled = true; // one duel per opponent — after that it just rallies
|
|
G.weapon = 0; // tennis rules for the duel: force plain balls, no tins
|
|
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked, centered card, colors return
|
|
zombieDuck(true); // fade the zombie music out for the 1:1 duel
|
|
voice('play_resume', 0.2); voice('sudden_death', 2.4);
|
|
}
|
|
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;
|
|
// the duel is played under tennis lights: lerp the palette home while it runs, back after
|
|
G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8));
|
|
|
|
// --- 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);
|
|
|
|
// --- racket zombies rally from the far court (splatter only, between hordes)
|
|
if (G.mode === 'splatter' && !G.horde)
|
|
for (const rz of zombies) if (rz.variant === 'r') racketAI(rz, dt);
|
|
|
|
// --- ball physics
|
|
if (ball.active) {
|
|
if (ball.flame) parts.push({ // fire trail: one ember per frame, brief and rising
|
|
x: ball.x, y: ball.y, z: ball.z, t: 0.22, fire: true,
|
|
vx: (Math.random() * 2 - 1) * 0.6, vy: 0.8, vz: (Math.random() * 2 - 1) * 0.6 });
|
|
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' && G.interlude) { // a roller nobody returned: point to the hitter
|
|
if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel
|
|
else resolveInterlude(ball.lastHit);
|
|
} else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming
|
|
spentBall(); sendBoy();
|
|
if (Math.random() < 0.12) voice('new_balls', 0.3);
|
|
} else if (ball.bounces === 1) pointTo(ball.lastHit, null);
|
|
}
|
|
}
|
|
// a racket zombie's return that gets past you (crosses your baseline) is a point for THEM
|
|
if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) {
|
|
ball.active = false;
|
|
if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel
|
|
else { if (!G.horde) splatPoint('O'); spentBall(); }
|
|
}
|
|
// splatter: retire spent or hopeless balls fast so the next serve comes right up.
|
|
// z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26)
|
|
// heads to the far court (vz<0), so it must not read as "out the back" on frame one.
|
|
// (during the interlude only spent balls retire early — flying balls must reach their
|
|
// bounce so the line call can happen; the umpire is watching again)
|
|
if (G.mode === 'splatter' && ball.active &&
|
|
((ball.lastHit === 'X' && ball.bounces >= 1)
|
|
|| (!G.interlude &&
|
|
((ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0) || Math.abs(ball.x) > 7.5)))) {
|
|
spentBall();
|
|
}
|
|
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) {
|
|
if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes
|
|
// auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into
|
|
// setupPoint() -> pretoss left the player locked in the serve posture while the gun
|
|
// fired (no movement outside 'rally', no serving during auto-fire). Back to rally.
|
|
if (G.horde || G.mgT > 0) {
|
|
G.phase = 'rally'; ball.active = false;
|
|
P.pose = 'idle'; P.poseT = 0; P.swingT = -1;
|
|
} else {
|
|
setupPoint();
|
|
if (G.pendingGame && G.mode === 'splatter') { // the next wave is announced as it arms
|
|
G.pendingGame = false;
|
|
G.flash = { text: 'GAME ' + (G.runGames + 1), t: 1.1 };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|