Four things from the phone. THE BAND NO LONGER BLINKS OUT. Cutting it at the instant of release made a fast rally strobe — band, gone, band, gone. hitServe now records the arc it actually fired (error included, so the ball tracks what it is consuming to within a centimetre) and the ball eats it from behind as it flies. The aim resolves into the shot instead of vanishing. It ends early when the ball does, because a shambler swallowing the shot is a cut you want. THE TARGET MARK SITS ON THE BODY. A ring on the grass pointed at the one part of a shambler the shot has nothing to do with — the ball strikes anywhere under 1.9 m and the wound lands on the torso. So the mark rides at chest height when there is a body to hit and lies flat only when the lane is empty. Filled and washed out in the band's own colour instead of a hard 2px ring, which stops it reading as a decal stuck to the lawn. x_howto REDRAWN for the current gesture: thumb dragging back to a print, the shot leaving as a curved band onto the shambler's chest, shadow dots beneath. The old one still taught a straight arrow. MACH_H 1.15 -> 1.55. Waist high read as a prop you could ignore. Verified on a cache-busted build (the pane was serving a stale config.js, so the first pass measured nothing): preview velocity and gravity still identical to the fired ball, same flight time in the trail, unravel smooth to the hit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1259 lines
73 KiB
JavaScript
1259 lines
73 KiB
JavaScript
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||
|
||
import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, 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, resetState } from './state.js';
|
||
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck } from './audio.js';
|
||
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
|
||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies } 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);
|
||
// pullQuality inverted: hitServe is handed a quality, not a drag, and the band it leaves behind is
|
||
// drawn at the same weight the live preview had.
|
||
export const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (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, bestErr = 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
|
||
const cone = TUNE.zSnap + TUNE.aimCone * along;
|
||
const err = Math.abs(dx * az - dz * ax) / cone; // 0 = dead on the ray, 1 = the cone's edge
|
||
if (err > 1) continue; // outside the cone entirely
|
||
// Pick the body you are actually POINTING AT, not merely the nearest one that happens to fall
|
||
// inside the cone. "First in the lane wins" let a close shambler off to one side steal a shot
|
||
// clearly aimed past it — the magnet Oliver felt. Range only breaks ties between equally
|
||
// well-aimed targets (TUNE.aimTie of slack), so a deliberate far shot still beats a sloppy
|
||
// near one while a genuine straight-ahead lane still eats the nearest body first.
|
||
const tie = TUNE.aimTie;
|
||
if (err < bestErr - tie || (Math.abs(err - bestErr) <= tie && along < near)) {
|
||
bestErr = Math.min(bestErr, err); near = along; zt = zb;
|
||
}
|
||
}
|
||
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;
|
||
// The player is right-handed: he tosses with his LEFT hand (viewer's left) and hits with the
|
||
// racket on the viewer's right, so the ball goes up almost over his head — a big offset either way
|
||
// lands it on the wrong side of one of those two frames. The opponent is drawn from the front and
|
||
// keeps the reach he was drawn with.
|
||
ball.x = sv.x + (sv.side === 'P' ? 0.12 : -0.35); 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;
|
||
|
||
// ---- ONE SOLVER for the slingshot serve. The reticle draws the arc the ball will actually fly,
|
||
// so the targeting and the ballistics below are shared with hitServe rather than reimplemented.
|
||
// The moment a preview and a shot come from two copies of this maths they drift, and then the
|
||
// preview is worse than nothing — it lies with confidence.
|
||
|
||
// dragServe snaps the ball to a FIXED contact before striking, which is the whole reason the arc
|
||
// is previewable: the launch point is known while you are still pulling. Both live here.
|
||
export const dragContact = () => ({ x: P.x + 0.12, y: 2.2, z: P.z });
|
||
|
||
// Where a splatter serve is aimed. `ex` is the aim error: hitServe passes the real random one,
|
||
// the preview passes 0, so the arc you see is the shot you MEANT rather than the shot you got.
|
||
// Returns null when this is not a free-aim/lane serve at all (the tennis serve keeps its box).
|
||
function splatServeTarget(sv, aim, s, x0, z0, ex = 0) {
|
||
if (G.mode === 'tennis' || G.interlude) return null; // the duel serves like tennis
|
||
const ft = aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) };
|
||
if (ft.zt) {
|
||
const T0 = Math.hypot(ft.zt.x - x0, ft.zt.z - z0) / s;
|
||
return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), ft }; // lead the walk
|
||
}
|
||
if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ft };
|
||
return null;
|
||
}
|
||
|
||
// The launch itself: topspin gravity, then trade pace for arc until the net is genuinely cleared.
|
||
// A shambler just the other side of the tape is only reachable on a loft.
|
||
function serveArc(x0, y0, z0, tx, tz, quality) {
|
||
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips
|
||
let v = solveShot(x0, y0, z0, tx, tz, s, g);
|
||
for (let i = 0; i < NET_LOFT_TRIES; i++) {
|
||
const tN = (COURT.netZ - z0) / v.vz;
|
||
const yN = y0 + 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) rather than a 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(x0, y0, z0, tx, tz, s, g);
|
||
}
|
||
return { v, g };
|
||
}
|
||
|
||
// What render draws while you pull: the whole shot, error-free, from the same two functions the
|
||
// release will use. null when there is nothing to show yet.
|
||
export function previewServe(aim) {
|
||
if (!aim || aim.mag < TUNE.pullMin) return null;
|
||
const q = pullQuality(aim.mag);
|
||
const c = dragContact();
|
||
const s = TUNE.serveSpeed + q * TUNE.serveQualitySpeed;
|
||
const st = splatServeTarget(P, aim, s, c.x, c.z);
|
||
if (!st) return null;
|
||
const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q);
|
||
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, !!st.ft.zt, q);
|
||
}
|
||
|
||
// The drawable form of a shot: everything render needs and nothing that would pin a zombie object
|
||
// alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at).
|
||
function shotBand(x0, y0, z0, v, g, tx, tz, zt, q) {
|
||
const T = Math.hypot(tx - x0, tz - z0) / (Math.hypot(v.vx, v.vz) || 1); // flight time to target
|
||
return { x0, y0, z0, v, g, tx, tz, zt, q, f: qualityFrac(q), T, t: 0 };
|
||
}
|
||
|
||
function hitServe(sv, quality, aim = null) {
|
||
const toFar = sv.side === 'P';
|
||
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||
// splatServeTarget + serveArc are shared with previewServe, so the arc drawn under your thumb
|
||
// is the one the ball flies. Only the aim error is added here and left out of the preview.
|
||
const st = splatServeTarget(sv, aim, s, ball.x, ball.z, (Math.random() * 2 - 1) * (1 - quality) * 0.8);
|
||
let boxX, tzs;
|
||
if (st) { boxX = st.tx; tzs = st.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 { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality);
|
||
// THE BALL ERASES ITS OWN AIM. Cutting the band at the instant of release made a fast rally
|
||
// strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it
|
||
// flies, so the aim resolves into the shot rather than blinking out of existence. This is the
|
||
// ACTUAL launch, error included, so the ball tracks the band it is consuming exactly.
|
||
if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, !!st.ft.zt, quality);
|
||
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);
|
||
// g: ball.g — a serve carries up to 2.3x gravity in topspin; clones integrated at flat
|
||
// TUNE.gravity floated long while the centre ball dived, which is the "wings lob off at
|
||
// the wrong height" report. They are the SAME shot, fanned.
|
||
extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, g: ball.g,
|
||
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) return;
|
||
m.in = 0; G.modal = m; whoosh(); // 0..1 entrance clock, ticked in update() behind the freeze
|
||
}
|
||
export function dismissModal() {
|
||
const m = G.modal; if (!m) return false;
|
||
// Still flying in? Eat the gesture but DON'T dismiss. A modal you haven't read yet must not be
|
||
// skippable by the same panic tap that was mid-air when it appeared — and returning false here
|
||
// would let that tap fall through into a serve, which is the swallowed-gesture bug all over again.
|
||
if (m.in < 1) return true;
|
||
G.modal = null; if (m.onDismiss) m.onDismiss();
|
||
return true; // the caller must swallow the gesture: dismissing must never also serve
|
||
}
|
||
|
||
// The umpire's asides. He has never once acknowledged that the match turned into a massacre, and
|
||
// that is the joke — so he keeps officiating it. But each line is tied to a SITUATION that actually
|
||
// justifies it, not drawn from a bag on a timer: Oliver's verdict on the first version was "too much
|
||
// and not situation aware.. they seem random". `aside()` is the single entry point and it enforces
|
||
// one shared cooldown (TUNE.asideEvery), so several triggers firing at once still yields one remark.
|
||
export function aside(name, delay = 0.2) {
|
||
if (G.mode !== 'splatter' || G.modal || G.changeover || G.interlude || G.card) return;
|
||
if (G.asideT > 0) return;
|
||
G.asideT = TUNE.asideEvery;
|
||
voice(name, delay, 0.95);
|
||
}
|
||
|
||
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.
|
||
// Which bodies gate the hand-clear after a won game. The racket zombie keeps its residence, and the
|
||
// ball machine is furniture — a standing threat you deal with WHEN YOU CHOOSE. Making either block
|
||
// progression would turn "decide what to prioritise" into "clear the chore list".
|
||
const blocksClear = z => z.variant !== 'r' && z.variant !== 'm';
|
||
|
||
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;
|
||
// ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from
|
||
// proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks
|
||
// everything else there — a boss must not walk on into a standing wave.
|
||
if (G.runGames % TUNE.bossEvery === 0)
|
||
G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a
|
||
// long stretch with no clear skips a chapter rather than queueing one, which is intended
|
||
|
||
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
|
||
if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted
|
||
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;
|
||
// The racket zombie is DEMOTED: he used to appear on every single game you won, which is exactly
|
||
// why he read as "the boss" and why a real boss would not have stood out next to him. He is an
|
||
// occasional opponent now — every racketEvery games — and the chapter boss is the wall.
|
||
if (side === 'P' && G.runGames % TUNE.racketEvery === 0 && !zombies.some(z => z.variant === 'r')
|
||
&& !G.bossFight)
|
||
spawnRacketZombie();
|
||
// the rogue ball machine gets wheeled out on the same kind of schedule, capped on court
|
||
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
|
||
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
|
||
spawnMachine();
|
||
// 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(blocksClear)) {
|
||
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 chapter boss outranks everything here, including a set: a boss walking on into a standing
|
||
// wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat.
|
||
if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; }
|
||
// The set/horde beat outranks the duel; 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 = TUNE.changeoverT; // seconds left, not a flag: "Time." is called when THIS runs out
|
||
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 };
|
||
aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match
|
||
// 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(healLine = null) {
|
||
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);
|
||
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
|
||
|
||
const name = SLOTS[w + 1];
|
||
// healLine folds the trainer's visit INTO this popup. `showModal` deliberately never clobbers an
|
||
// open modal (a kill cascade can try to open two in one frame), so when both happened the heal
|
||
// modal opened first and the weapon one was lost entirely — Oliver: "the weapon message is lost".
|
||
showModal({ title: name, body: healLine ? [...WDESC[name], '', healLine] : 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
|
||
G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration)
|
||
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 };
|
||
}
|
||
|
||
// ---- the chapter boss: every TUNE.bossEvery games, the court is cleared and ONE thing walks on.
|
||
// No other spawns until it is down, so the fight is legible and so banking a bomb for it is a real
|
||
// decision (which is the point — tins arrive every 3 games, so there was never a reason to save one).
|
||
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
|
||
// instant death. G.bossFight holds the chapter number while it runs.
|
||
export function startBossFight(chapter) {
|
||
if (G.bossFight) return;
|
||
G.bossFight = chapter;
|
||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
|
||
G.horde = null; G.mgT = 0; G.clearing = false;
|
||
ball.active = false; setupPoint();
|
||
spawnChapterBoss(chapter);
|
||
zombieDuck(true); // the music drops out for it
|
||
voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85);
|
||
const art = BOSS_ART[(chapter - 1) % BOSS_ART.length];
|
||
G.card = { title: 'CHAPTER ' + chapter, sub1: art.name, sub2: 'IT THROWS. RETURN IT.',
|
||
color: UI.bad, t: TUNE.setCardT };
|
||
G.flash = null;
|
||
if (!G.seen.boss) { // the rules change: a wall, and a thing that shoots back
|
||
G.seen.boss = true;
|
||
showModal({ title: 'IT BRINGS A MACHINE', color: UI.bad,
|
||
body: ['IT THROWS BALLS AT YOU', 'SWING TO RETURN THEM — OR SWISH TO BAT THEM AWAY',
|
||
'A HIT COSTS BLOOD. WIN A DUEL TO HEAL.'] });
|
||
}
|
||
}
|
||
|
||
// the chapter boss is down: the parked game beat resumes and normal spawning comes back
|
||
export function endBossFight() {
|
||
if (!G.bossFight) return;
|
||
const c = G.bossFight; G.bossFight = 0;
|
||
zombieDuck(false);
|
||
shots.length = 0;
|
||
cheer(3.4, 0.9); voice('unbelievable', 0.3);
|
||
G.card = { title: 'CHAPTER ' + c + ' DOWN', sub1: 'THE STANDS ARE ON THEIR FEET',
|
||
sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT,
|
||
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
|
||
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT + TUNE.bossDeathT;
|
||
// hand back the game beat the boss interrupted — same contract resolveInterlude uses
|
||
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
|
||
}
|
||
|
||
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);
|
||
// winning the duel patches you up. THE reason to want a duel rather than merely tolerate it —
|
||
// it is the only healing in the game, and blood is the only thing you cannot buy back otherwise.
|
||
let healLine = null;
|
||
if (G.hp < G.hpMax) {
|
||
const before = G.hp;
|
||
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
|
||
voice('ump_trainer', 0.2, 0.95);
|
||
healLine = '+ WOUNDS TREATED · NOW ' + G.hp + '/' + G.hpMax;
|
||
}
|
||
deliverTin(healLine); // win the point, win the next tin — and the trainer, in ONE popup
|
||
} 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
|
||
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
|
||
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
|
||
// further behind on every set banked (the second horde was where every run ended)
|
||
G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow, mgRate: TUNE.mgRate * grow };
|
||
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)
|
||
aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated'
|
||
// that used to play right before it belongs to the transition, not here.)
|
||
G.flash = { text: 'THEY KEEP COMING', t: 1.2 };
|
||
}
|
||
|
||
// hidden shortcut: tapping the umpire three times summons the horror early
|
||
// Back to a fresh run WITHOUT reloading the page. The reload was the honest reset, but it also tore
|
||
// down the AudioContext, so the menu theme restarted from silence every time you died — and the menu
|
||
// is supposed to pick up the track that was already playing over the death summary. Every mutable
|
||
// bag has a pristine copy (state.resetState) and the two modules with timers of their own put them
|
||
// back themselves, so nothing here has to know the shape of any of it.
|
||
export function restartRun() {
|
||
resetZombies(); resetInput(); resetState(); setupPoint();
|
||
}
|
||
|
||
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.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock
|
||
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, g: ball.g });
|
||
ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null;
|
||
ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall
|
||
hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all
|
||
}
|
||
|
||
// The swish cooldown, scaled by how crowded you are. Oliver died on every game-20 horde: the wave
|
||
// grows with hordeGrow but the panic button was a flat 1 s, so past a certain density it simply could
|
||
// not keep up. He picked "melee fires more frequently with horde density" over shrinking the horde.
|
||
function meleeCooldown() {
|
||
let near = 0;
|
||
for (const zb of zombies) if (zb && Math.hypot(zb.x - P.x, zb.z - P.z) < TUNE.meleeCrowdR) near++;
|
||
return Math.max(TUNE.meleeCoolMin, TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd));
|
||
}
|
||
|
||
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) {
|
||
// measure the crowd BEFORE the swipe — it kills everything in reach, so counting afterwards
|
||
// counts the SURVIVORS and the cooldown barely moved no matter how swarmed you were
|
||
const cool = meleeCooldown();
|
||
if (meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = cool;
|
||
}
|
||
// AN INCOMING SHOT OWNS THE TAP. The parry used to live behind the `phase === 'rally'` branch at the
|
||
// bottom of this function — and in splatter you are in `pretoss` almost all of the time, so a tap
|
||
// aimed at a machine's shot tossed the serve ball instead and the return was, in practice,
|
||
// impossible. Oliver could not land one. A hostile ball in the air is the most visible thing on the
|
||
// screen, so "the tap is a swing at it" keeps one gesture meaning one thing at any given moment.
|
||
if (G.mode === 'splatter' && shots.some(s => Math.hypot(s.x - P.x, (s.z - P.z) * 0.8) < TUNE.parryArm)) {
|
||
P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true;
|
||
swish(0.3);
|
||
return;
|
||
}
|
||
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 (G.eatenT < EATEN_BURST) eatenBleed(dt); // phase A: they are still tearing at you
|
||
if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore
|
||
// phase C: the summary IS the end menu — hand the soundtrack back to the menu theme. Not at
|
||
// killPlayer: the death itself already has the massacre call and the crowd's groan over it.
|
||
if (was < EATEN_SUMMARY && G.eatenT >= EATEN_SUMMARY) { zombieDuck(true); menuDuck(false); }
|
||
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; }
|
||
// A card can wait before it shows. The chapter card used to land on the same frame as the boss
|
||
// died, so the biggest gore burst in the game happened underneath it and nobody ever saw it.
|
||
if (G.card) {
|
||
if (G.card.wait > 0) G.card.wait -= dt;
|
||
else { 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.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = 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;
|
||
const m = G.modal; // the entrance is the ONE thing that animates behind the freeze
|
||
if (m.in < 1 && (m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud
|
||
return;
|
||
}
|
||
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||
G.idleT += dt;
|
||
if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside())
|
||
// the fired band unravels behind the ball. It also goes the moment the ball does — a shambler
|
||
// eating the shot ends the band with the splat, which is a cut you want.
|
||
if (G.trail && ((G.trail.t += dt) >= G.trail.T || !ball.active)) G.trail = null;
|
||
// "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any
|
||
// other beat that overwrote overT while the breather ran (a melee kill taking a game, say)
|
||
// had the umpire calling time on a pause that was never a changeover.
|
||
if (G.changeover > 0 && (G.changeover -= dt) <= 0) { G.changeover = false; voice('time', 0, 0.95); }
|
||
const w = BOUNCE_IDLE.tempo;
|
||
// idle in the serve stance and not touching anything: after a beat, show the pull gesture
|
||
if (G.phase === 'pretoss' && !G.aim) G.idleHintT += dt; else G.idleHintT = 0;
|
||
if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce
|
||
// the toss/smash pose belongs to the SHOT, not to the aim: while the slingshot is drawn (G.aim)
|
||
// the server stays in the pretoss stance and only snaps into 'serve' when hitServe fires.
|
||
if (G.phase === 'toss' && !G.aim) { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
|
||
|
||
// --- player movement (near half only, generous margins)
|
||
// 'rally' ONLY, deliberately. Letting the player walk during the between-point beats was tried
|
||
// and vetoed on feel: you never walk freely during play, so suddenly being able to between points
|
||
// reads as the game breaking, not as a fix. The frozen-changeover complaint is answered by
|
||
// holding the DEAD instead (see updateZombies), and the stuck pose by the reset below.
|
||
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;
|
||
}
|
||
}
|
||
// Stay ON SCREEN — always, not just in auto-fire. P_BOUNDS.x is 6.5 m, but at the baseline in
|
||
// portrait the camera only shows about ±4 m either side of the centre line, so walking to the
|
||
// bound put the player clean off the edge (x = -6.5 projects to screen -61 of 270). Floored at
|
||
// the sideline so every legal tennis ball stays catchable — the court is never out of bounds.
|
||
const xLim = Math.max(xVis, COURT.W / 2);
|
||
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
|
||
}
|
||
// ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only
|
||
// handles swing/serve), and P.runT stops advancing outside the block, so a beat that began
|
||
// mid-stride left him welded to a single run frame — the "stuck mid-animation" report.
|
||
else if (P.pose === 'run') { P.pose = 'idle'; P.poseT = 0; }
|
||
// 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
|
||
// volley-length window while a hostile shot is in the air too, or the swing clock expires at
|
||
// 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance
|
||
const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1);
|
||
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(blocksClear)) 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.bossFight
|
||
&& 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) {
|
||
// A boss (or a duel) OUTRANKS the horde, and pendingHorde is a latch: it is armed when the set
|
||
// banks and cashed by whichever 'over' beat expires next — which, at game 30, is the chapter
|
||
// card, because 30 is both a set and a boss. The wave then poured onto the boss stage. Leave the
|
||
// latch alone and let the beat AFTER the fight cash it: the set still gets its horde, later.
|
||
if (G.pendingHorde && !G.bossFight && !G.interlude) { // 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;
|
||
}
|
||
// 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 };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|