Files
splatter-tennis/gameplay.js
T
wuwiandClaude Opus 5 2787778ad2 Splatter: ball economy — one ball, fetch it back, no more toss-spam
The slingshot serve didn't fix the gameplay because four things were free at
once: pure-hold auto-target with predictive lead, a hardcoded 0.62 serve
quality, infinite ammo with an instant mid-rally re-toss, and a melee swish on
every single tap that cleaved the whole cluster. Perfecting the pretoss/toss
rhythm beat the game; no skill required.

The ball is scarce now. A spent ball LIES where it died (G.loose, ringed on
court) and there is no serve until it's back in your hand: walk it down
(instant, and the only reason to leave the baseline), let a boy scoop it, wait
out TUNE.looseMax, or watch a shambler swallow it — the one real punishment.
Fire rate is a positioning decision instead of a thumb-tempo one.

Also: the far backstop rebounds a ball with pace left, still lethal on the way
home and volleyable (trySwing lets P re-hit their own ball in splatter); melee
drops to nearest-one-only on a 1s cooldown; and the mid-rally re-toss is gone.
With no ball in hand the thumb-drag steers your legs instead of the reticle —
without that, walking your ball down is impossible on touch.

TUNE.looseMax is the fire-rate knob. Measured: ~4s per serve standing still,
~0.4s if you go and get it. 600 taps in 10s now yield exactly one serve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 14:18:07 +03:00

907 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, 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' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim
? (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' && !G.interlude // the duel serves like tennis: to the box, not free-aim
? (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
const i = side === 'P' ? 0 : 1;
if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card
else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games
else G.afterClear = 'game'; // even game: a beat, then the next wave
G.clearWinner = side;
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)
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
// play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear()
// fires (see update()). If the winning shot already emptied the court, proceed at once.
if (zombies.some(z => z.variant !== 'r')) {
G.clearing = true; ball.active = false;
G.flash = { text: 'CLEAR THE COURT', t: 1.8 };
G.phase = 'over'; G.overT = TUNE.gameBeatT; // a short beat to register the game, then the serve loop resumes for the mop-up
} else proceedAfterClear();
} 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);
}
}
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
export function proceedAfterClear() {
G.clearing = false;
const mode = G.afterClear, side = G.clearWinner;
G.afterClear = null;
if (mode === 'set') {
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over)
sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM',
sub2: 'SETS ' + G.sets[0] + ' – ' + G.sets[1],
color: side === 'P' ? '#e6ee3a' : '#ff5050', t: TUNE.setCardT };
G.pendingHorde = true; // the horde begins when the card's pause ends
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
} else if (mode === 'changeover') {
G.pendingGame = true; startChangeover(); // 'GAME N' after the breather
} else { // 'game': even-game beat, then GAME N announces the next wave
G.pendingGame = true; ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT;
}
}
// 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(); giveBall(); // the breather always leaves you holding a ball
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 ball economy: a spent ball doesn't evaporate into a fresh serve — it LIES where it died.
// Reclaim it by walking it down (fast, but it drags you off the baseline), let a boy fetch it
// (slow and safe), or watch a shambler swallow it (very slow). This is what stops serve spam:
// the fire rate is now a positioning decision, not a thumb-tempo one.
if (G.mode === 'splatter' && !G.interlude && !G.horde && !(G.mgT > 0)) {
G.loose = { t: 0 }; ball.y = 0; ball.collected = false;
sendBoy(ball.x, ball.z);
}
}
// a ball is in your hand again — serving is armed. Called wherever the rules hand you one.
export function giveBall() { G.loose = false; G.fetchT = 0; }
export const armed = () => !G.loose && G.fetchT <= 0;
// 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
P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only)
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;
// splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you —
// volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.)
const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P';
if (ball.lastHit === ch.side && !own) 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;
P.z = HOME.P.z; // brace at the baseline for the burst (finger steers x only)
G.flash = { text: 'GUN', t: 1.0 };
return true;
}
// slingshot serve, stage one: the pull-back starts the toss animation. The ball rises while
// you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss.
export function serveToss() {
if (G.mode === 'splatter' && !armed()) return; // no ball in hand — nothing to toss
if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
}
// release of the slingshot 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' || score.server !== 'P') return;
if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle
if (G.phase !== 'pretoss' && G.phase !== 'toss') return; // one ball: no re-serving over a live rally
if (tryGunBurst()) return;
if (!armed()) 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
// a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the
// default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool.
if (G.mode === 'splatter' && G.meleeT <= 0 && meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = TUNE.meleeCool;
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(); }
// splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from
// pretoss WITH a ball in hand: one ball, and it has to come back before you fire it again.
// A flick is the aimed one-motion variant (the reticle). The duel is exempt: it serves like tennis.
if (G.mode === 'splatter' && !G.interlude && score.server === 'P' && G.phase === 'pretoss') {
const flick = takeFlickAim();
if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check
}
if (G.phase === 'pretoss' && score.server === 'P') {
if (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball
if (G.mode === 'splatter' && !armed()) return; // empty-handed: the tap was just the swish above
tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel)
}
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' && !G.interlude ? 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.card) { G.card.t -= dt; if (G.card.t <= 0) G.card = 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' && (G.mode !== 'splatter' || armed())
&& Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce (only if you hold one)
if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
// --- player movement (near half only, generous margins)
// splatter also moves during pretoss: with a ball lying on the court, walking is the whole point
if (G.phase === 'rally' || (G.mode === 'splatter' && G.phase === 'pretoss')) {
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
// splatter: anything travelling back toward you is yours to run down — your own rebound
// included. The assist puts you there; the volley timing stays honest.
const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0);
// 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
}
// splatter: you START at the baseline but you're free to advance up-court (bounded at the net by
// P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still
// recovers you toward the baseline between shots on touch; the duel is plain tennis as ever.
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.meleeT > 0) G.meleeT -= dt;
// --- the loose ball: three ways it comes back, each with its own price in seconds
if (G.loose) {
G.loose.t += dt;
if (Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8) < TUNE.pickupR) { giveBall(); pock(0.5, 900); } // you ran it down
else if (ball.collected) giveBall(); // a boy scooped it and tossed it back
else if (zombies.some(zb => zb.variant !== 'r' && Math.hypot(zb.x - ball.x, zb.z - ball.z) < TUNE.eatBallR)) {
G.loose = false; G.fetchT = TUNE.fetchEaten; // one of them swallowed it. New balls, please.
G.flash = { text: 'BALL EATEN', t: 1.3 };
pock(0.9, 130); voice('new_balls', 0.4);
}
else if (G.loose.t > TUNE.looseMax) giveBall(); // out of reach (far court, off court): the boys just bring you one
} else if (G.fetchT > 0 && (G.fetchT -= dt) <= 0) G.fetchT = 0;
if (G.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance
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
giveBall(); // ...and a fresh one in hand, whatever happened to the last
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 && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way
}
// the far backstop: a LIVE ball doesn't leave the field, it rebounds off the fence and comes
// back down-court — still lethal on the way home, and yours to volley. Spent balls just die.
if (G.mode === 'splatter' && !G.interlude && ball.active && ball.lastHit === 'P'
&& ball.z < TUNE.backstopZ && ball.vz < -3) { // only a ball with real pace left comes back; a roller just stops
ball.z = TUNE.backstopZ;
ball.vz *= -TUNE.backstopKeep; ball.vx *= 0.7;
ball.vy = Math.max(ball.vy, TUNE.backstopPop); // the fence kicks it up, so it carries back through the crowd
pock(0.5, 200); // chain-link thud
}
// 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 && !G.aim) { // uncaught toss → retoss (a held slingshot aim keeps the toss up)
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.pendingHorde) { G.pendingHorde = false; startHorde(); return; } // the set card's pause is over — release the horde
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 };
}
}
}
}
}