The freeze first: G.mgT only counted down inside `G.phase === 'rally'`, so anything that moved the phase mid-burst — a scheduled duel calling setupPoint — stranded it above zero forever. mgT > 0 is one of keyPressed's early returns, so no tap could serve again, and the yellow shape over the player's head was drawMgRacket's muzzle flash still being drawn. Fixed at the root (the clock ticks in every phase, only the pellets are gated on rally) and semantically: starting a duel cancels any burst, because the duel is tennis. The mop boy now does an actual round: walk to the biggest nearby stain, stand on it and push the mop while the cycle animates in place, scrub it out, move to the next, then leave. Gore fades ~3.5x faster while he is stopped and working than when walking past, so the stain dies under him instead of fading as he drifts by. boyMaxJobs caps the round so a kill streak can't employ him forever. Applause: the clip is 4.16s and always plays in full, but every cheer() passed a shorter beat, so the stand sat down while it was still audible. Floored at the clip length. New, much bolder clap frame — the re-roll came back brighter and bluer, which would have flashed the whole stand, so it is colour-matched to frame one per channel on mean and sd before quantising. Net and umpire were both wrong since forever: netW was the singles-stick width (10.06) against a court drawn to the doubles lines, so the net stopped 0.46m short each side; the chair's near legs stood 0.45m inside the sideline. Both now sit exactly on the doubles line. 26-game soak clean, no stalls. Serve returns 134/150 = 89.3% +/-2.5% with the wider net (the bar is inside one sigma), 30 human-tapped points = 50 hits / 0 whiffs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1011 lines
56 KiB
JavaScript
1011 lines
56 KiB
JavaScript
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||
|
||
import { TUNE, UI, APPLAUSE_T, EATEN_BURST, 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, gore, explode, meleeSwipe, extras, parts, pellets, playerGore } 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 (slingshot). The pull is a RAY, not a landing point: its DIRECTION picks the
|
||
// lane you fire down, its LENGTH is power — and nothing else. Pulling further does NOT reach
|
||
// further, which is the whole fix: a shambler at the far baseline costs the same short pull as
|
||
// one on top of you, so the gesture always fits on the screen. The first body standing in the
|
||
// lane is the target; the cone widens with range so distant lanes stay pickable.
|
||
export const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin)));
|
||
export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pullQMin);
|
||
|
||
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
|
||
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
|
||
if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
|
||
const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z
|
||
let zt = null, near = 1e9;
|
||
for (const zb of zombies) {
|
||
const dx = zb.x - ch.x, dz = zb.z - ch.z;
|
||
const along = dx * ax + dz * az; // how far down the lane it stands
|
||
if (along <= 0) continue; // behind the shot
|
||
if (Math.abs(dx * az - dz * ax) > TUNE.zSnap + TUNE.aimCone * along) continue; // outside the cone
|
||
if (along < near) { near = along; zt = zb; } // the FIRST one in the lane eats it
|
||
}
|
||
const R = zt ? near : TUNE.aimReach; // empty lane: the ball just flies the full length of it
|
||
const tx = Math.max(-6.5, Math.min(6.5, ch.x + ax * R));
|
||
const tz = Math.max(1, Math.min(24.5, ch.z + az * R));
|
||
return { tx, tz, zt, q, f };
|
||
}
|
||
|
||
// 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);
|
||
if (none) return null; // no direction, no lock. Free predictive auto-aim was the reason a
|
||
// bare rhythm of taps could clear the court without any aiming at all.
|
||
let best = null, bs = 1e9;
|
||
for (const zb of zombies) {
|
||
let 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 < NET_LOFT_TRIES; 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
|
||
|
||
// how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target
|
||
// sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35).
|
||
const NET_LOFT_TRIES = 12;
|
||
|
||
function hitServe(sv, quality, aim = null) {
|
||
const toFar = sv.side === 'P';
|
||
let 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 * TUNE.serveSpin); // the harder you hit it, the harder it dips
|
||
// CLEAR THE NET. A shambler standing just the other side of the tape is only reachable on a
|
||
// loft, so trade pace for arc until the ball actually gets over. doHit has done this since
|
||
// forever; the serve never did, which is why every shot at a net-hugger died in the tape.
|
||
// The trade is real: the loftier it goes the slower it arrives, and the walk-lead below was
|
||
// computed for the original pace — a lob is a genuinely less reliable shot.
|
||
let v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
|
||
for (let i = 0; i < NET_LOFT_TRIES; i++) {
|
||
const tN = (COURT.netZ - ball.z) / v.vz;
|
||
const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN;
|
||
// The sim only samples the net plane once a frame, so a steeply DIVING ball can test below
|
||
// the tape even when the closed form clears it. Charge margin for exactly that sampling
|
||
// error (descent rate × the worst frame step) instead of doHit's flat quality cushion —
|
||
// that cushion is paid for in pace, and it was taxing precisely the shots meant to hurt.
|
||
const margin = 0.06 + Math.abs(v.vy - g * tN) * 0.04; // 0.04 s = main.js's dt clamp
|
||
if (tN <= 0 || yN >= COURT.netH + margin) break;
|
||
s *= 0.9; 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]];
|
||
}
|
||
|
||
// ---- the modal: the only thing in the game that stops the world. Play resumes on a tap, not
|
||
// on a timer, so a popup can be READ. showModal never clobbers an open one (a kill cascade can
|
||
// try to open two in the same frame) and only ever touches G — never the zombie array it may
|
||
// be called from mid-iteration.
|
||
export function showModal(m) { if (!G.modal) G.modal = m; }
|
||
export function dismissModal() {
|
||
const m = G.modal; if (!m) return false;
|
||
G.modal = null; if (m.onDismiss) m.onDismiss();
|
||
return true; // the caller must swallow the gesture: dismissing must never also serve
|
||
}
|
||
|
||
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)
|
||
// floored at the CLIP LENGTH: the sample always plays out in full, so the stand must stay on its
|
||
// feet until it stops. `seconds` still lets a big moment run longer than the audio.
|
||
crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T);
|
||
}
|
||
|
||
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++;
|
||
// the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance
|
||
// at a weapon, but you still have to win the point to be handed one. Fires at the next clear.
|
||
if (G.runGames === TUNE.duelFirst ||
|
||
(G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
|
||
G.duelDue = 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;
|
||
// A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled
|
||
// duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.)
|
||
// The set/horde beat outranks it; the duel just waits for the next clear.
|
||
if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; }
|
||
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' ? UI.pick : UI.hot, 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;
|
||
// out with the mop, across the near court where the gore is — NOT to the ball: a dead ball
|
||
// parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is
|
||
// now long enough that an empty court reads as the game having stalled.
|
||
sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work
|
||
G.flash = { text: 'CHANGEOVER', t: 1.4 };
|
||
// no tins here anymore — weapons are won in the duel, never handed out on a schedule
|
||
}
|
||
|
||
// ---- 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.
|
||
const WDESC = { // what the tin you just won actually does — one line, shown on the pickup modal
|
||
BOMB: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'],
|
||
TRIPLE: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'],
|
||
FIRE: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'],
|
||
GUN: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'],
|
||
};
|
||
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 + TUNE.tinTriples);
|
||
else if (w === 2) G.flames = add(G.flames + TUNE.tinFlames);
|
||
else G.guns = add(G.guns + TUNE.tinGuns); // machine-gun charges — 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);
|
||
const name = SLOTS[w + 1];
|
||
showModal({ title: name, body: WDESC[name], icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w],
|
||
iconH: 40, color: UI.warm });
|
||
}
|
||
|
||
// ---- 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.
|
||
// This is now the ONLY source of weapons in the game: win the point or leave empty-handed.
|
||
export function startDuel() {
|
||
if (G.interlude) return;
|
||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
|
||
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
|
||
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
|
||
G.mgT = 0; pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order.
|
||
G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces
|
||
ball.active = false; setupPoint();
|
||
zombieDuck(true); // fade the zombie music out for the 1:1 duel
|
||
voice('play_resume', 0.2); voice('sudden_death', 2.4);
|
||
if (!G.seen.duel) { // the rules change back — worth one real interruption, once per run
|
||
G.seen.duel = true;
|
||
showModal({ title: 'SUDDEN DEATH', color: UI.hot,
|
||
body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'] });
|
||
} else G.flash = { text: 'SUDDEN DEATH', t: 1.6 };
|
||
}
|
||
|
||
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++; // 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');
|
||
}
|
||
if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled
|
||
G.afterClear = G.afterDuel; G.afterDuel = null;
|
||
proceedAfterClear();
|
||
}
|
||
}
|
||
|
||
// 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
|
||
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();
|
||
// the mechanic changed under you — the one popup that gets an illustration, because it has
|
||
// to TEACH the gesture, not just announce it
|
||
showModal({ title: 'THE MATCH HAS CHANGED', color: UI.hot, icon: 'x_howto', iconH: 120,
|
||
body: ['PULL BACK TO AIM — THEN LET GO', 'THE FURTHER YOU PULL, THE HARDER IT HITS',
|
||
'A WEAK SHOT ONLY STAGGERS THEM'] });
|
||
});
|
||
}
|
||
|
||
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 (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 === 'over') { ball.active = false; setupPoint(); } // mid-beat pull: arm the next shot now
|
||
if (G.phase !== 'pretoss' && G.phase !== 'toss' && G.phase !== 'rally') return;
|
||
if (tryGunBurst()) return;
|
||
if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent
|
||
// Firing while the last ball still flies is fine again — the brake is the pull itself, not the
|
||
// rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that
|
||
// walked the player around between shots was the confusing part, not the fire rate.
|
||
if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone
|
||
extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5 });
|
||
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, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
|
||
}
|
||
|
||
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
|
||
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'; }
|
||
}
|
||
|
||
// ------------------------------------------------------------- ball boys ----
|
||
// The fetch boy is unchanged: sprint the width of the court, scoop the dead ball in passing.
|
||
function fetchRound(b, dt) {
|
||
const step = b.dir * 7.5 * dt;
|
||
b.x += step; b.runT += dt; b.dist = (b.dist || 0) + Math.abs(step);
|
||
if (!ball.collected && !ball.active) {
|
||
const dz = ball.z - b.z;
|
||
b.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little
|
||
if (Math.abs(b.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true;
|
||
}
|
||
if (Math.abs(b.x) > 13.5) G.boy = null;
|
||
}
|
||
|
||
// The mop boy actually does a JOB: walk to a stain, stand on it and push the mop until it's
|
||
// gone, then move to the next one, then leave. He used to slide the width of the court in a
|
||
// straight line at constant speed, which read as moonwalking past the mess rather than cleaning it.
|
||
function mopRound(b, dt) {
|
||
b.runT += dt;
|
||
if (b.mopT > 0) { // ON a stain: no travel, but the push cycle keeps animating (dist drives frames)
|
||
b.mopT -= dt;
|
||
b.dist = (b.dist || 0) + TUNE.boyMopSpeed * 0.6 * dt; // the mop travels even when he doesn't
|
||
if (b.mopT <= 0) b.jobs--; // that one's done — look for the next
|
||
return;
|
||
}
|
||
// pick the next target: the biggest stain he can still reach, ignoring ones already scrubbed out
|
||
if (b.tx === undefined && b.jobs > 0) {
|
||
let best = null, bs = -1;
|
||
for (const d of gore) {
|
||
if (d.a < 0.15 || d.z < COURT.netZ - 2) continue; // faded, or up the far end where he can't work
|
||
const score = d.a * d.r - Math.hypot(d.x - b.x, d.z - b.z) * 0.04; // big and close first
|
||
if (score > bs) { bs = score; best = d; }
|
||
}
|
||
if (best) { b.tx = best.x; b.tz = best.z; }
|
||
else b.jobs = 0; // nothing left to clean — go home
|
||
}
|
||
const working = b.tx !== undefined && b.jobs > 0;
|
||
const gx = working ? b.tx : b.dir * 14, gz = working ? b.tz : b.z; // no target left: walk off the wing
|
||
const dx = gx - b.x, dz = gz - b.z, d = Math.hypot(dx, dz);
|
||
const step = Math.min(TUNE.boyMopSpeed * dt, d);
|
||
if (d > 0.001) { b.x += dx / d * step; b.z += dz / d * step; b.dist = (b.dist || 0) + step; }
|
||
if (Math.abs(dx) > 0.05) b.dir = dx > 0 ? 1 : -1; // face the way he's walking
|
||
if (working && d <= 0.12) { b.mopT = TUNE.boyMopDwell; b.tx = undefined; b.tz = undefined; } // arrived: get to work
|
||
if (Math.abs(b.x) > 13.5) G.boy = null; // off the wing — round over
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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;
|
||
const was = G.eatenT; G.eatenT += dt;
|
||
if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore
|
||
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||
if (G.boomFlash > 0) G.boomFlash -= 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;
|
||
// A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers,
|
||
// so nobody gets eaten while reading a popup. idleT keeps running for the blink.
|
||
if (G.modal) { G.idleT += dt; return; }
|
||
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
|
||
// 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;
|
||
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.
|
||
// The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is
|
||
// pure upside for weapon supply.
|
||
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)
|
||
startDuel();
|
||
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.mop ? mopRound(boy, dt) : fetchRound(boy, dt);
|
||
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) { // the set card's pause is over — but the horde is a different CONTROL MODE
|
||
G.pendingHorde = false; // (racket auto-fires, the finger steers you absolutely),
|
||
showModal({ title: 'THEY KEEP COMING', color: UI.hot, // so it gets a real block, not just a card
|
||
body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'],
|
||
onDismiss: startHorde });
|
||
return;
|
||
}
|
||
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 };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|