The weapon-test cheat sat on the SCOREBOARD, where a single tap is the miss-log
overlay — so the thing you tap to read a log was also the thing that opened a
sandbox mid-run, three taps in, by accident. That tap does one job again.
The cheats now live behind five taps on the splash logo, where nothing marks
them and nothing in play can reach them:
WEAPON TEST the old sandbox. It stocked only the BARREL weapons, on a screen
called the weapon test — it stocks every weapon in the game now,
eight of the nine slots.
BOSS TEST a portrait of every chapter boss, built by asking bossArt() what
each chapter gets, so the list cannot drift from what a run
actually spawns. Pick one and you land in a real fight at that
chapter's game number with the full arsenal at ×99.
Neither is a special mode: both ride the transition out with laserScene's trick
(force the turn, then step the ten-second cinematic out in one frame) and hand
you the ordinary update loop, ordinary scoring, ordinary death. A test that
plays by different rules tells you nothing about the game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1691 lines
103 KiB
JavaScript
1691 lines
103 KiB
JavaScript
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||
|
||
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS } from './config.js';
|
||
import { P, O, ball, score, crowd, G, court, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js';
|
||
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } 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 , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
|
||
|
||
// ------------------------------------------------------------- ballistics ----
|
||
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
|
||
// `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a
|
||
// shambler's chest, and solving to the ground under one is what made the ball strike the ankles.
|
||
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) {
|
||
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
|
||
return { vx: dx / T, vz: dz / T, vy: (ty - 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, t }; // t rides along: the landing ring contracts on it
|
||
}
|
||
|
||
// 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.
|
||
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.
|
||
const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (1 - TUNE.pullQMin)));
|
||
// A pull that STOPS in the band at the top of the draw, rather than merely reaching it. See
|
||
// TUNE.perfectBand for why the old "quality == 1" test praised almost every serve.
|
||
export const pullPerfect = mag => mag >= TUNE.pullFull && mag <= TUNE.pullFull * TUNE.perfectBand;
|
||
|
||
// The umpire has never once acknowledged the massacre, and he is not about to start now: a clean
|
||
// shot gets the same line it would get at Wimbledon. Cycled rather than random — the same word twice
|
||
// running is what made one word read as a stuck HUD element rather than as praise.
|
||
const PRAISE = ['PERFECT', 'TEXTBOOK', 'IMMACULATE', 'OH, LOVELY', 'UNPLAYABLE', 'SHOT OF THE DAY'];
|
||
let praiseN = 0;
|
||
// ...and he does not say it every time. Same cooldown model as the asides (aside()): once you have
|
||
// learned the band you hit it constantly, and a HUD that talks on every other serve is noise
|
||
// whatever the word is. The bright click in the pull is the reliable feedback; this is the garnish.
|
||
// Returns the word (or null, on cooldown) — the CALLER decides where and when it appears, because
|
||
// the word must land at the IMPACT, not at the release (Oliver: "the reactions are playing after
|
||
// shooting the ball, not when the ball hits"). A serve arms it on ball.praise and the contact/
|
||
// bounce pops it; a rally hit pops it at the racket, where contact and praise are the same moment.
|
||
function praiseWord() {
|
||
if (G.praiseT > 0) return null;
|
||
G.praiseT = TUNE.praiseEvery;
|
||
return PRAISE[praiseN++ % PRAISE.length];
|
||
}
|
||
|
||
// Court dust: a kicked-up puff at ground level, tinted per surface — chalk on clay, flecks on the
|
||
// grass, grit on the slab. Squarely inside the "blood, fire, smoke" procedural exemption; the ball's
|
||
// bounce and the sidestep both kick it, which is what pins fast feet and fast balls to the ground.
|
||
const DUST_COL = { grass: '#8fa06a', clay: '#c9895a', hard: '#8494a4' };
|
||
function dustPuff(x, z, n = 5, sp = 1) {
|
||
for (let k = 0; k < n; k++) parts.push({
|
||
x: x + (Math.random() * 2 - 1) * 0.15, y: 0.05, z: z + (Math.random() * 2 - 1) * 0.1,
|
||
vx: (Math.random() * 2 - 1) * 1.2 * sp, vy: 0.5 + Math.random() * 0.9 * sp,
|
||
vz: (Math.random() * 2 - 1) * 1.2 * sp,
|
||
t: 0.25 + Math.random() * 0.2, spark: DUST_COL[court.id] || '#98948a' });
|
||
}
|
||
|
||
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';
|
||
if (ch.side === 'P') G.passStreak = 0; // a landed swing ends the tap-to-hit lesson
|
||
// 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. Same crossesNet guard as serveArc's,
|
||
// for the same reason: a splatter volley at a body on your own side has no tape to clear, and
|
||
// without this it was lobbed at a fraction of its pace. (In tennis the ball always crosses.)
|
||
const margin = 0.06 + 0.22 * quality;
|
||
const crossesNet = (ball.z - COURT.netZ) * (tz - COURT.netZ) < 0;
|
||
for (let i = 0; crossesNet && 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;
|
||
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; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them
|
||
ball.chain = 0; // ...and the slice dies with the shot that earned it
|
||
ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact
|
||
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
|
||
// TENNIS ONLY. It answers "did that land in?", and splatter does not ask: `out` could never be
|
||
// true there, so it was a permanent yellow ring on the grass carrying no information — and
|
||
// sitting under the shambler for 1.3 s, which is longer than the gap before you aim again.
|
||
// That ring, not the new mark, is the circle that seemed to live under their feet.
|
||
if (G.mode === 'tennis') {
|
||
const ok = 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 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs)
|
||
G.hitstop = 0.05; pock(0.5, 1600);
|
||
const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact
|
||
if (w) popText(ball.x, ball.y, ball.z, w);
|
||
}
|
||
ch.pose = 'swing'; ch.poseT = 0;
|
||
}
|
||
|
||
// ----------------------------------------------------------------- serve ----
|
||
function tossBall(sv) {
|
||
ball.active = true; ball.bounces = 0; 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';
|
||
}
|
||
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;
|
||
// ty: aim at the CHEST of whatever this is, so the ball arrives on the body it is going to
|
||
// wound. Scaled off the same height render draws it at, so a brute is struck on the brute.
|
||
return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), // lead the walk
|
||
ty: zombieH(ft.zt) * Z_CHEST, ft };
|
||
}
|
||
if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ty: 0.08, ft }; // empty lane: onto the grass
|
||
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, ty = 0.08) {
|
||
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, ty);
|
||
// THERE IS ONLY A NET TO CLEAR IF THE SHOT CROSSES IT. A shambler on your own side sits short of
|
||
// the tape, so the ball never reaches that plane — but `tN` is still positive (the net is ahead
|
||
// in the direction of travel, the ball simply lands first), the height test there is far below
|
||
// the tape, and the loop cut pace all twelve times. Measured: every same-side shot left the
|
||
// racket at 28% of nominal, as a slow loop, which is exactly the shot you need to be fast —
|
||
// those are the ones about to eat you.
|
||
const crossesNet = (z0 - COURT.netZ) * (tz - COURT.netZ) < 0;
|
||
for (let i = 0; crossesNet && 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, ty);
|
||
}
|
||
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, st.ty);
|
||
// ...and WHETHER THIS ONE SLICES. The perfect window was invisible until now: quality saturates
|
||
// at pullFull, so an overpull and a perfect pull drew an identical band and you were being asked
|
||
// to hit a target the game never showed you. Now the band goes chalk-white inside it. Plain balls
|
||
// only, because only a plain ball slices (see hitServe).
|
||
return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q,
|
||
pullPerfect(aim.mag) && G.weapon === 0 && !G.temp);
|
||
}
|
||
|
||
// 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). `ty` is
|
||
// the arrival height, so the mark render paints sits exactly where the ball is going.
|
||
function shotBand(x0, y0, z0, v, g, tx, tz, ty, zt, q, pf = false) {
|
||
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, ty, zt, q, pf, f: qualityFrac(q), T, t: 0 };
|
||
}
|
||
|
||
// ---- THE SLICE CAROM. A sliced ball does not steer. At the instant it kills something it is
|
||
// STRUCK AGAIN — one new velocity out of the same solver a serve uses, aimed at the next body's
|
||
// chest — and from the next frame it is an ordinary ball under ordinary gravity.
|
||
// This replaces a hook that turned toward its target a little more every frame, and the reason is
|
||
// not that the hook was buggy twice (it was) but that it was the wrong thing to build: EVERY other
|
||
// ball in this game flies a parabola you can read, so the one that thinks reads as a glitch even on
|
||
// the shots where it works. Oliver, on watching one circle a wounded shambler: "an orbiting tennis
|
||
// ball midair, nobody knows what is going on." A carom cannot orbit, cannot hover and cannot
|
||
// outlive its shot, because after this function returns there is no slice left running anywhere —
|
||
// which is also why the wound case needed no fix of its own. A ball spent on a body it failed to
|
||
// kill simply drops, like every other spent ball.
|
||
// The hop draws its own band, white, so the chain leaves a chalk arc from the corpse to the next
|
||
// one — the same object the aim preview and the serve already use, given a target of its own.
|
||
export function sliceCarom(tgt, speed) {
|
||
const g = ball.g || TUNE.gravity, ty = zombieH(tgt) * Z_CHEST;
|
||
const v = solveShot(ball.x, ball.y, ball.z, tgt.x, tgt.z, speed, g, ty);
|
||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||
G.trail = shotBand(ball.x, ball.y, ball.z, v, g, tgt.x, tgt.z, ty, true, 1, true);
|
||
}
|
||
|
||
// `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss
|
||
// ritual earns it on a 45 ms timing window, while the slingshot's quality saturates at pullFull and
|
||
// so can never fail the old `quality > 0.999` test once you have pulled far enough.
|
||
function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
|
||
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, st ? st.ty : 0.08);
|
||
// 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.ty, !!st.ft.zt, quality,
|
||
perfect && G.mode === 'splatter' && !G.interlude && G.weapon === 0 && !G.temp);
|
||
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
|
||
ball.praise = null; // ...and any word a spent ball never got to pop dies with it
|
||
// 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.
|
||
// ...and the DUEL is plain balls, full stop. startDuel forces slot 0, but slot 0 routes through
|
||
// G.temp — so with a temp weapon riding it every duel serve armed and spent the temp (a laser
|
||
// fired instead of the ball and aborted the point). Tennis rules means 'ball', temp or no temp.
|
||
const wsel = G.mode === 'splatter' && !G.interlude ? G.weapon : 0;
|
||
const wid = G.interlude || (wsel === 0 && !G.temp) ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0;
|
||
ball.wid = armed ? wid : 'ball'; // an unstocked tin throws a plain ball — file the kills that way
|
||
// THE SLICE. A perfect serve with a PLAIN ball is cut: it hooks body to body (see splatter).
|
||
// Plain only — a bomb, a flame ball and a laser already have an identity, and stacking a carom
|
||
// on top of one would read as the tin misbehaving rather than as something you did.
|
||
ball.chain = perfect && ball.wid === 'ball' ? TUNE.sliceHops : 0;
|
||
// THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else
|
||
// about the gesture is untouched — the pull, the aim, the release — which is the whole point of it.
|
||
if (wid === 'laser' && armed) {
|
||
spendSlot(wsel);
|
||
fireBeam(ball.vx, ball.vz);
|
||
ball.active = false; G.phase = 'over'; G.overT = 0.5;
|
||
sv.pose = 'serve'; sv.poseT = 0;
|
||
return;
|
||
}
|
||
ball.bomb = wid === 'bomb' && armed;
|
||
if (ball.bomb) spendSlot(wsel);
|
||
ball.flame = wid === 'fire' && armed;
|
||
if (ball.flame) spendSlot(wsel);
|
||
if (wid === 'triple' && armed) { // one serve, three balls: clones fanned ±6°
|
||
spendSlot(wsel);
|
||
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, wid: 'triple',
|
||
vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c });
|
||
}
|
||
}
|
||
// (spendSlot already hands you back plain balls when the last one goes)
|
||
// tennis only, same reason as doHit's: a splatter serve lands at a shambler's feet, and the ring
|
||
// sat there in yellow for 1.3 s saying nothing — right where the aim mark is trying to be read.
|
||
if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !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 (sv.side === 'P') G.tossDrops = 0; // a served toss ends the apex lesson
|
||
if (perfect && sv.side === 'P') {
|
||
G.hitstop = 0.05;
|
||
// the word is ARMED here and pops at the IMPACT — first body the ball touches, or its first
|
||
// bounce. Praising at release meant the HUD spoke while the ball was still mid-air.
|
||
ball.praise = praiseWord();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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. It only ever touches G — never the zombie array it may be
|
||
// called from mid-iteration.
|
||
// A second popup opened in the same beat QUEUES rather than being dropped. It used to be dropped
|
||
// ("if (G.modal) return"), which is why the trainer and the weapon had to be crammed into one card
|
||
// after a won duel — the second one simply vanished. They are two things, so they are two popups.
|
||
export function showModal(m) {
|
||
m.in = 0; // 0..1 entrance clock, ticked in update() behind the freeze
|
||
if (G.modal) { G.modalQ.push(m); return; }
|
||
G.modal = m; whoosh();
|
||
}
|
||
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 = G.modalQ.shift() || null;
|
||
if (G.modal) { G.modal.in = 0; whoosh(); } // the next one flies in on its own entrance
|
||
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) });
|
||
G.passStreak++; // two of these in a row and the ghost hand shows the tap (render.drawHitHint)
|
||
}
|
||
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".
|
||
// A barrel must NOT gate the hand-clear: it is optional loot, and making it block progression would
|
||
// turn "decide whether that is worth a shot" into a chore every time one is on court.
|
||
const blocksClear = z => !'rmo'.includes(z.variant);
|
||
|
||
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();
|
||
// ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp),
|
||
// because the late game is where the rack has to carry you. Still one at a time: two glowing
|
||
// pink things on court would out-shout everything the palette uses to say "this one is dangerous".
|
||
if (side === 'P' && G.runGames % Math.max(1, TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp)) === 0
|
||
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
|
||
spawnBarrel();
|
||
// 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') {
|
||
startChangeover();
|
||
} else { // 'game': even-game beat, then the next wave arms
|
||
ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT;
|
||
}
|
||
// (no 'GAME N' flash on either path anymore — the bar under the scoreboard prints the game
|
||
// number permanently, and the flash was the same news shouted a second time)
|
||
}
|
||
|
||
// 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
|
||
// no CHANGEOVER flash — the countdown panel says 'CHANGEOVER · RESUMING IN n' for the whole
|
||
// breather (render), so the flash was the same word with a shorter lifespan
|
||
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. RACK_MAX + 1 slots, and only slot 0 is fixed: it is BALL (infinite), or
|
||
// an overflow weapon riding it temporarily. Slots 1..RACK_MAX are what you have picked up, in order.
|
||
// EVERYTHING READS THROUGH THESE FOUR, so nothing else has to know how the rack is stored — and
|
||
// nothing else may hardcode how MANY slots there are. `slotOf` exists because endBurst did exactly
|
||
// that ("[0,1,2,3,4].find(...)"), so the moment the rack grew past five, a burst weapon sitting in
|
||
// slot 5..9 could never be found and never spent: beacon, flamer and rocket sat at their pickup
|
||
// count forever. One accessor, one place that knows the range.
|
||
const slotEntry = i => (i === 0 ? G.temp : G.rack[i - 1]) || null;
|
||
export const slotId = i => slotEntry(i)?.id || (i === 0 ? 'ball' : null);
|
||
export const slotStock = i => { const e = slotEntry(i); return e ? e.n : (i === 0 ? Infinity : 0); };
|
||
const slotOf = id => { // -1 if you are not carrying it
|
||
for (let i = 0; i <= RACK_MAX; i++) if (slotId(i) === id) return i;
|
||
return -1;
|
||
};
|
||
export function selectWeapon(i) {
|
||
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
|
||
// A HORDE is the one place the rack stays locked: your finger is steering the man, the racket
|
||
// fires itself, and there is no gesture left to spend on a tin. Everything else can be switched
|
||
// out of, including mid-burst — see below.
|
||
if (G.horde) return;
|
||
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
|
||
// A BURST IS NOT A COMMITMENT. Committing to a burst tin used to lock the rack for its whole
|
||
// window — eleven seconds of beacon lobs while a late-game crowd walks into you is a death you
|
||
// can watch coming and do nothing about, which is exactly what Oliver hit. Tapping another slot
|
||
// ABORTS it: the tin is spent (you did fire it) and the serve loop comes straight back, the same
|
||
// handover updateZombies does when the window ends on its own. Both input paths land here — the
|
||
// rack tap and the 1-0 keys — so the abort lives here rather than in either of them.
|
||
if (G.mgT > 0) {
|
||
// ...but spending a tin can EMPTY it, and spendSlot splices an empty entry out, which shifts
|
||
// every slot above it down one. A tap is at a slot; what you meant was the weapon you could see
|
||
// in it. So resolve to the id first and look it up again afterwards — otherwise aborting a
|
||
// beacon in slot 1 to reach the rocket in slot 4 hands you whatever slid into slot 4.
|
||
const want = slotId(i);
|
||
G.mgT = 0; endBurst(); setupPoint();
|
||
const j = slotOf(want);
|
||
i = j >= 0 ? j : 0; // the tin we just spent WAS the one tapped: fall back to BALL
|
||
}
|
||
G.weapon = i; // temp has just been cleared, and then it is BALL again)
|
||
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
|
||
}
|
||
|
||
// Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides
|
||
// slot 0 as `temp` — Oliver's rule: a fifth is always allowed, because a barrel arms on the spot and
|
||
// is spent quickly, so refusing it would mean a barrel you shot did nothing.
|
||
export function grantWeapon(id, n = WEAPONS[id].tin) {
|
||
const have = G.rack.find(e => e.id === id);
|
||
if (have) { have.n = Math.min(TUNE.wCap, have.n + n); G.weapon = G.rack.indexOf(have) + 1; }
|
||
else if (G.rack.length < RACK_MAX) { G.rack.push({ id, n }); G.weapon = G.rack.length; }
|
||
else if (G.temp?.id === id) { G.temp.n = Math.min(TUNE.wCap, G.temp.n + n); G.weapon = 0; }
|
||
else { G.temp = { id, n }; G.weapon = 0; }
|
||
return G.weapon;
|
||
}
|
||
|
||
// Spend one from a slot, and hand you back plain balls when it runs dry. The temp weapon does not
|
||
// leave an empty dimmed slot behind the way a rack entry does — slot 0 simply becomes BALL again.
|
||
function spendSlot(i) {
|
||
const e = slotEntry(i);
|
||
if (!e) return;
|
||
if (--e.n > 0) return;
|
||
// Spent, so it LEAVES — a slot is for what you are carrying, and an exhausted weapon sitting in
|
||
// one is a quarter of the rack you can never pick anything up into. (The old fixed rack kept its
|
||
// empty slots dimmed, which was right when the slot meant "BOMB goes here" and is wrong now.)
|
||
if (i === 0) G.temp = null; // slot 0 goes back to being the infinite ball
|
||
else G.rack.splice(i - 1, 1);
|
||
G.weapon = 0;
|
||
}
|
||
|
||
// 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 TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out
|
||
export function deliverTin() {
|
||
// WALK the ladder to the next rung you have room for, rather than cycling blindly onto one that
|
||
// is already at wCap. Blind cycling meant `grantWeapon` clamped the top-up to a no-op and a won
|
||
// duel silently handed you nothing — and past about the thirteenth duel three rungs of four are
|
||
// full, so most late-run duels paid out in literally nothing. Oliver: "later in the game a won
|
||
// duel does not give you weapons anymore."
|
||
const stock = id => { const s = slotOf(id); return s < 0 ? 0 : slotStock(s); }; // rack OR the temp slot
|
||
// The barrel weapons are RUNGS OF THE SAME LADDER, not a fallback. They used to be paid out only
|
||
// once all four tins hit wCap — and now that wCap is effectively no cap that branch would never
|
||
// fire again, so a late duel could only ever hand you a fifth bomb. Unlocked ones only, so this
|
||
// still cannot hand you a rocket in game three.
|
||
const ladder = [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w => G.runGames >= BARREL_WEAPONS[w].from)];
|
||
let id = null;
|
||
for (let k = 0; k < ladder.length; k++) {
|
||
const cand = ladder[(G.deliveries + k) % ladder.length];
|
||
if (stock(cand) < TUNE.wCap) { id = cand; G.deliveries += k + 1; break; }
|
||
}
|
||
// Every tin AND every barrel weapon full is a real state and not a bug, so say so plainly instead
|
||
// of miming a handover that cannot happen. No boy, no umpire line — nothing was delivered.
|
||
if (!id) { G.flash = { text: 'RACK FULL', t: 1.1 }; return; }
|
||
const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking)
|
||
grantWeapon(id);
|
||
if (wasPlain) G.weapon = 0;
|
||
sendBoy(); voice('new_balls', 0.6);
|
||
aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently."
|
||
// THE PRIZE GETS ITS OWN CARD: the tin's picture, its name, and how many. Nothing else — the
|
||
// first-sight MODAL is gone (a popup that freezes a live court mid-run costs runs) and so is the
|
||
// prose that replaced it. You have just won a point; you want to know what landed and how much.
|
||
G.card = { title: WEAPONS[id].label, sub1: '×' + WEAPONS[id].tin,
|
||
icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT };
|
||
}
|
||
|
||
// ---- TEST MODE, the back room's WEAPON TEST (five taps on the splash logo — it used to be a
|
||
// triple-tap on the scoreboard). A sandbox for trying the new content without playing twenty games
|
||
// to reach it: a steady medium wave and EVERY weapon on the rack, topped back up as you spend it.
|
||
// It does NOT stop the run — games, duels and bosses all still happen underneath; it only overrides
|
||
// the difficulty dials and keeps the rack stocked.
|
||
export function toggleTestMode() {
|
||
G.testMode = !G.testMode;
|
||
if (!G.testMode) { G.flash = { text: 'TEST MODE OFF', t: 1.4 }; return false; }
|
||
if (G.mode === 'tennis') forceZombies(); // no zombies to test against in a tennis match
|
||
stockTestRack();
|
||
G.flash = { text: 'TEST MODE', t: 1.6 };
|
||
showModal({ title: 'WEAPON TEST', color: UI.pink,
|
||
body: ['A STEADY CROWD, MEDIUM SPEED', 'EVERY WEAPON, KEPT FULL',
|
||
'', 'IT RUNS UNTIL THE RUN DOES'] });
|
||
return true;
|
||
}
|
||
|
||
// EVERY weapon on the rack at full stock — the four tins as well as the four barrel weapons, which
|
||
// is eight of the nine slots. It stocked only the barrel weapons before, on a screen called the
|
||
// weapon test. Called on entry and then kept topped up in update, so a burst you commit (which
|
||
// spends the whole tin) is available again a moment later.
|
||
function stockTestRack() {
|
||
for (const id of Object.keys(WEAPONS)) {
|
||
const have = G.rack.find(e => e.id === id);
|
||
if (have) have.n = Math.max(have.n, WEAPONS[id].tin);
|
||
else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin });
|
||
}
|
||
}
|
||
function tickTestMode(dt) {
|
||
if (!G.testMode) return;
|
||
stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and
|
||
} // one rolling across the sandbox was just something else to look at
|
||
|
||
// ---- 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; endBurst(); 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; endBurst(); 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 = bossArt(chapter);
|
||
G.card = { title: 'CHAPTER ' + chapter, sub1: art.name, sub2: art.line || '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',
|
||
'SWIPE SIDEWAYS TO STEP OUT OF THE WAY',
|
||
'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);
|
||
// hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It
|
||
// used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game
|
||
// that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it
|
||
// had drawn a single frame, and the beat was cut to setCardT. The boss card wins now.
|
||
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
|
||
// The longest fight in the game deserves more than the two seconds a routine set gets. Big word,
|
||
// long hold — the chapter and the crowd move down to the subs where they belong.
|
||
// (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a
|
||
// card that repeats what the HUD already says is the clutter this pass is meant to remove.)
|
||
G.card = { title: 'EPIC!', sub1: 'CHAPTER ' + c + ' DOWN',
|
||
sub2: 'THE STANDS ARE ON THEIR FEET', color: UI.pick, t: TUNE.bossWinT,
|
||
wait: TUNE.bossDeathT }; // ...after the body has finished coming apart
|
||
ball.active = false;
|
||
// a duel the handback just started owns the phase; otherwise hold the boss beat, and never
|
||
// SHORTEN a longer one the handback set (a changeover's 5 s outlasts this)
|
||
if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); }
|
||
}
|
||
|
||
// ---- THE DEADLOCK WATCHDOG.
|
||
// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
|
||
// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
|
||
// game sat there with the gun racket on screen. This is the net.
|
||
//
|
||
// It cannot misfire on a legitimate wait, and that is the whole design: every legitimate reason you
|
||
// cannot serve — a horde, a burst, a changeover, the duel's announcement, an 'over' beat — has a
|
||
// CLOCK counting down toward letting you act. So the test is not "how long has the player been
|
||
// blocked" (a horde blocks you for fifteen honest seconds); it is "is the thing that should be
|
||
// releasing me actually moving". If none of those numbers changes for stuckT seconds while the
|
||
// player is blocked and no popup is waiting on them, the state is dead and nothing will revive it.
|
||
//
|
||
// It also LOGS what was frozen, into the same miss log the scoreboard tap already shows — a lock
|
||
// that unsticks itself and leaves no evidence is worse than one that does not.
|
||
let stuckT = 0, stuckSig = '';
|
||
function watchdog(dt) {
|
||
if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate
|
||
const blocked = G.horde || G.mgT > 0 || G.changeover || G.phase === 'over'
|
||
|| (G.interlude && G.interlude.introT > 0)
|
||
|| (G.phase === 'toss' && ball.y < 2.0); // a toss on the floor: only a held aim keeps it there
|
||
if (!blocked) { stuckT = 0; return; }
|
||
const sig = [G.horde && G.horde.t, G.mgT, G.changeover, G.interlude && G.interlude.introT,
|
||
G.phase === 'over' && G.overT, ball.y].map(v => (+v || 0).toFixed(2)).join('|');
|
||
if (sig !== stuckSig) { stuckSig = sig; stuckT = 0; return; } // something is still moving — fine
|
||
if ((stuckT += dt) < TUNE.stuckT) return;
|
||
logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`,
|
||
phase: G.phase, sig, aim: !!G.aim });
|
||
G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false;
|
||
G.aim = null; endBurst(); setupPoint();
|
||
stuckT = 0; stuckSig = '';
|
||
}
|
||
|
||
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
|
||
// ...and the trainer comes out. 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. It
|
||
// gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line
|
||
// of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
|
||
if (G.hp < G.hpMax) {
|
||
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
|
||
voice('ump_trainer', 0.2, 0.95);
|
||
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
|
||
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
|
||
}
|
||
} 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 SIDESTEP. Free walking is 'rally'-only (see the movement block) and that is staying —
|
||
// but it left you welded to the spot through a whole boss fight, because a boss is fought from the
|
||
// serve stance and the one finger is already spent on the slingshot. So: a horizontal swipe is a
|
||
// committed step, not a joystick. It integrates OUTSIDE the movement block so it works in every
|
||
// phase, and it carries invulnerability, which is the only reason to make one.
|
||
export function dodge(dir) {
|
||
if (G.mode !== 'splatter' || G.modal || G.dodgeCd > 0 || G.dodgeT > 0) return;
|
||
G.dodgeDir = dir; G.dodgeT = TUNE.dodgeT; G.dodgeCd = TUNE.dodgeCool; G.dodgeI = TUNE.dodgeIFrame;
|
||
dustPuff(P.x, P.z, 6); // the committed step kicks up the court — it also marks where the i-frame began
|
||
G.idleHintT = 0; // ...and the apron demo goes away, like any other gesture. The touch path nulls
|
||
// G.aim to fire this, and the demo's own guard only watches G.aim.
|
||
P.facing = dir;
|
||
// ...but a swing in flight keeps its pose. You can step out of the way mid-shot and the shot still
|
||
// lands (swingT is untouched); clobbering the pose only threw away the animation for it.
|
||
if (P.pose !== 'swing' && P.pose !== 'serve') { P.pose = 'run'; P.poseT = 0; }
|
||
swish(0.22);
|
||
}
|
||
|
||
// How far out he may stand. 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 him clean off the
|
||
// edge. Floored at the sideline so every legal tennis ball stays catchable — the court is never out
|
||
// of bounds. Shared by the movement block and the dodge; both were inline in the former.
|
||
const xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
|
||
const xLimit = () => Math.max(xVisible(), COURT.W / 2);
|
||
|
||
// 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
|
||
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));
|
||
// THE CAMERA HAS TO BE THERE BEFORE IT HAPPENS. The zoom used to start ON the turn — and the
|
||
// curve render applies is `sin(p*PI)`, which is ZERO at p=0, so the punch-in only reached full
|
||
// magnification half a second AFTER he had already changed. You watched the aftermath every time.
|
||
// It starts at 1.8 with T 3.2 so the peak (p=0.5) lands exactly on the turn at 3.4, holds through
|
||
// it, and pulls back out as the mob walks on — which becomes the reveal of what else is coming.
|
||
once('closein', 1.8, () => { G.zoom = { x: O.x, z: O.z, k: 2.1, t: 0, T: 3.2 }; });
|
||
// ...and the turn itself is a sprite SWAP, one frame to the next, with nothing to sell it. So it
|
||
// lands on a beat: the world stops dead for a moment (hitstop), the screen flashes, he comes
|
||
// apart and puts himself back together wrong. The freeze is what makes a swap read as an EVENT
|
||
// rather than as a glitch — the eye is given something to blame the change on.
|
||
once('turn', 3.4, () => {
|
||
G.hitstop = 0.16; // the world holds its breath
|
||
G.boomFlash = 0.16; G.shake = { t: 0.7, mag: 7 };
|
||
shriek(); splat(0.9); boom();
|
||
for (let k = 0; k < 40; k++) parts.push({ // he bursts, and what stands up is not him
|
||
x: O.x, y: 0.5 + Math.random() * 1.5, z: O.z,
|
||
vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 3.5,
|
||
t: 0.5 + Math.random() * 0.7, dark: Math.random() < 0.45 });
|
||
gore.push({ x: O.x, z: O.z, r: 1.1, a: 0.9 });
|
||
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'); G.shake = { t: 0.5, mag: 6 }; });
|
||
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();
|
||
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
|
||
// NO modal here anymore — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches
|
||
// the pull instead: prime the idle clock so the apron demo starts miming IMMEDIATELY rather
|
||
// than after the usual beat of inaction, and it keeps coming back whenever the player stalls.
|
||
G.idleHintT = TUNE.pullHintAfter;
|
||
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
|
||
});
|
||
}
|
||
|
||
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);
|
||
// a deferred praise word that met no body lands with the ball — the DRAW was still perfect
|
||
if (ball.praise) { popText(ball.x, 0.3, ball.z, ball.praise); ball.praise = null; }
|
||
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
|
||
// A ball SPENT on a body ('X' — it wounded the racket zombie without killing him) gets no line
|
||
// call in either direction: it is nobody's shot anymore. Without this case the P-or-not test
|
||
// below read 'X' as HIS ball — bounce out and you were handed the duel, bounce twice in-court
|
||
// and you LOST it for hitting him. Same rule as the dead-roller path in update().
|
||
if (ball.lastHit === 'X') { spentBall(); return; }
|
||
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) {
|
||
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; }
|
||
}
|
||
}
|
||
|
||
// A BURST weapon isn't a ball — committing to one braces you at the baseline for a window (the same
|
||
// mode a horde uses) and it fires ITSELF for `len` seconds while your finger only steers. G.mgT is
|
||
// the mode timer, which is why the eighteen `!(G.mgT > 0)` guards across five files never changed.
|
||
// There was briefly a third kind that fired one round per tap; Oliver read it as the weapon being
|
||
// broken, so a barrel weapon is now either this or a serve. Nothing waits for a tap.
|
||
function tryArmGun() {
|
||
if (G.mode !== 'splatter') return false;
|
||
const id = slotId(G.weapon), w = WEAPONS[id];
|
||
if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false;
|
||
G.gunId = id;
|
||
G.mgT = w.len ?? TUNE.mgPickupLen;
|
||
// The tin is spent when the burst ENDS, not when it starts. Spending on commit removed the rack
|
||
// entry immediately, so the countdown bar — which draws on the slot holding G.gunId — had no slot
|
||
// to draw on and every burst weapon ran with no progress bar at all. Oliver spotted it on the
|
||
// flamethrower; it was true of all four.
|
||
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: w.label, t: 1.0 };
|
||
return true;
|
||
}
|
||
|
||
export function serveToss() {
|
||
// A NEW WIND-UP ENDS THE LAST SHOT'S FOLLOW-THROUGH. Without this he latched: hitServe sets
|
||
// pose 'serve' with poseT 0, that only expires after POSE_TIME, and the pull frame is only drawn
|
||
// from 'idle' — so firing faster than 0.38 s reset the timer before it could ever expire and he
|
||
// was welded into p_smash. Racket permanently up, balls appearing out of it, no wind-up at all,
|
||
// which is precisely the late-game rapid-fire look. Clearing it here restores pull/smash/pull.
|
||
if (score.server === 'P' && P.pose === 'serve') { P.pose = 'idle'; P.poseT = 0; }
|
||
if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P);
|
||
}
|
||
|
||
// The burst is over: NOW the tin is spent. Called from updateZombies when mgT runs out.
|
||
export function endBurst() {
|
||
const id = G.gunId;
|
||
G.gunId = null;
|
||
if (!id) return;
|
||
const slot = slotOf(id);
|
||
if (slot >= 0) spendSlot(slot);
|
||
}
|
||
|
||
// The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction
|
||
// and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim
|
||
// if there is one and otherwise fires straight up-court.
|
||
function tryLaser() {
|
||
if (G.mode !== 'splatter' || G.interlude) return false;
|
||
const w = WEAPONS[slotId(G.weapon)];
|
||
if (!w || !w.beam || slotStock(G.weapon) <= 0) return false;
|
||
const flick = takeFlickAim();
|
||
const dx = flick ? flick.fx : 0, dz = flick ? -Math.abs(flick.fy) : -1;
|
||
spendSlot(G.weapon);
|
||
fireBeam(dx, dz || -1);
|
||
ball.active = false; G.phase = 'over'; G.overT = 0.5;
|
||
P.pose = 'serve'; P.poseT = 0;
|
||
return true;
|
||
}
|
||
|
||
// 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 (tryArmGun()) 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, wid: ball.wid });
|
||
ball.active = true; ball.collected = false; ball.bounces = 0;
|
||
ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall
|
||
// a full pull is quality 1.0, but only a pull that STOPS at the top of the draw is praised
|
||
hitServe(P, pullQuality(aim.mag), aim, pullPerfect(aim.mag));
|
||
}
|
||
|
||
// 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) {
|
||
// Dead men toss no balls. The restart tap lives in main.js (maybeRestart); everything below —
|
||
// melee, parry, and especially the pretoss branch — must not run while the death animation plays,
|
||
// or a panic tap conjured a ball that hung frozen midair over the corpse (update early-returns
|
||
// in eaten mode, so a tossed ball never got a single frame of physics).
|
||
if (G.mode === 'eaten') return;
|
||
// Desktop sidestep: an arrow/A/D EDGE outside 'rally', where the walk axes do nothing anyway.
|
||
// Inside a rally the same keys still walk, so one key never means two things at one moment.
|
||
if (code === 'ArrowLeft' || code === 'KeyA' || code === 'ArrowRight' || code === 'KeyD') {
|
||
if (G.phase !== 'rally') dodge(code === 'ArrowLeft' || code === 'KeyA' ? -1 : 1);
|
||
return;
|
||
}
|
||
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 (tryArmGun()) return; // a burst weapon selected: the gesture arms it, no ball
|
||
// ...and a LASER never tosses one either. The ray IS the weapon, so a two-stage toss ritual was
|
||
// throwing a ball into the air and then firing something else entirely.
|
||
if (tryLaser()) return;
|
||
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); }
|
||
crowd.cheer = Math.max(0, crowd.cheer - dt); // the stand sits back down over the death too —
|
||
// a cheer live at the kill used to clap forever
|
||
if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // settles rather than freezing
|
||
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||
if (G.boomFlash > 0) G.boomFlash -= dt;
|
||
for (let i = pops.length - 1; i >= 0; i--) if ((pops[i].t -= dt) <= 0) pops.splice(i, 1); // no DOUBLE KILL frozen over the feast
|
||
updateZombies(dt);
|
||
return;
|
||
}
|
||
if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null;
|
||
if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // the tape settles
|
||
if (G.zoom && (G.zoom.t += dt) >= G.zoom.T) G.zoom = null; // scripted punch-in (render applies it)
|
||
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;
|
||
}
|
||
// The flash is FROZEN behind a popup too, and it has to be: it used to tick above the return, so
|
||
// any flash raised in the same beat as a modal spent its whole 1.1 s invisible behind the dim and
|
||
// was gone before you had tapped. That is exactly what made a won duel look like it paid nothing
|
||
// late in a run — the weapon modal is first-sight-only, so once you have seen a tin the label
|
||
// flash IS the announcement, and the trainer's popup was eating it every time you were wounded.
|
||
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.
|
||
// BELOW the modal return, like the flash: a card raised in the same beat as a popup used to
|
||
// burn its whole life dimmed-out underneath it — now it holds and plays AFTER the dismissal.
|
||
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; }
|
||
}
|
||
for (let i = pops.length - 1; i >= 0; i--) if ((pops[i].t -= dt) <= 0) pops.splice(i, 1); // reaction pops decay
|
||
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||
G.idleT += dt;
|
||
if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside())
|
||
if (G.praiseT > 0) G.praiseT -= dt; // ...and the praise words' (see praise())
|
||
// 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 = xVisible();
|
||
let mgSteer = false, mgMoving = 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; mgMoving = true; }
|
||
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 (x = -6.5 projects to screen -61 of 270).
|
||
const xLim = xLimit();
|
||
P.x = Math.max(-xLim, Math.min(xLim, P.x));
|
||
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
|
||
// mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a
|
||
// player sliding left and right under auto-fire counted as standing still and never ran —
|
||
// "when he's moving left and right the run animation is not applied".
|
||
const moving = mgMoving || mx || mz || assist !== 0 || G.dodgeT > 0;
|
||
P.runT += moving ? dt : 0;
|
||
// Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then
|
||
// faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic
|
||
// run animation" whether he was moving or not. The pose follows MOVEMENT in every mode now, so
|
||
// auto-fire uses the ordinary run animation and standing still is standing still.
|
||
if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
|
||
if (mx) P.facing = mx > 0 ? 1 : -1;
|
||
else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing
|
||
}
|
||
// ...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' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; }
|
||
// The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent
|
||
// in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him
|
||
// off the edge of the screen either.
|
||
if (G.dodgeCd > 0) G.dodgeCd -= dt;
|
||
if (G.dodgeI > 0) G.dodgeI -= dt;
|
||
if (G.dodgeT > 0) {
|
||
G.dodgeT -= dt;
|
||
const xLim = xLimit();
|
||
P.x = Math.max(-xLim, Math.min(xLim, P.x + G.dodgeDir * (TUNE.dodgeDist / TUNE.dodgeT) * dt));
|
||
P.runT += dt; // the legs move, so the run cycle advances
|
||
if (G.dodgeT <= 0) G.dodgeDir = 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);
|
||
tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
|
||
if (G.meleeT > 0) G.meleeT -= dt;
|
||
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out
|
||
watchdog(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) {
|
||
// rubber band: every game HE has taken makes him flub more — the opening match must not
|
||
// be a wall a new player grinds against for rounds (see aiFlubLoss in config)
|
||
if (Math.random() < TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss) 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) {
|
||
// fire trail. One brief ember a frame read as a spark, not as a ball that is ON FIRE — this is
|
||
// the weapon whose whole pitch is "kills on any touch and sets the others alight", so it now
|
||
// sheds THREE embers a frame with real spread, they live long enough to hang as a tail behind
|
||
// the arc, and every third leaves a puff of smoke drifting off the top of it.
|
||
if (ball.flame) {
|
||
for (let k = 0; k < 3; k++) parts.push({
|
||
x: ball.x + (Math.random() * 2 - 1) * 0.14, y: ball.y + (Math.random() * 2 - 1) * 0.14,
|
||
z: ball.z + (Math.random() * 2 - 1) * 0.14, t: 0.34 + Math.random() * 0.26, fire: true,
|
||
vx: (Math.random() * 2 - 1) * 0.9, vy: 0.9 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.9 });
|
||
if (Math.random() < 0.34) parts.push({ x: ball.x, y: ball.y + 0.15, z: ball.z,
|
||
vx: (Math.random() * 2 - 1) * 0.3, vy: 0.7, vz: (Math.random() * 2 - 1) * 0.3,
|
||
t: 0.5 + Math.random() * 0.4, smoke: true });
|
||
}
|
||
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);
|
||
G.netRipple = { x: ball.x, t: 0.4 }; // the tape carries the hit: a damped wobble where it clipped
|
||
}
|
||
}
|
||
if (ball.y <= 0 && ball.vy < 0) {
|
||
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
|
||
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
|
||
if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court
|
||
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;
|
||
// dropping the toss twice running means the second tap isn't landing — the ghost hand then
|
||
// rides the next toss and presses at the apex (render.drawApexHint)
|
||
else if (G.mode === 'tennis') G.tossDrops++;
|
||
}
|
||
}
|
||
|
||
// 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),
|
||
if (!G.seen.horde) { // so its FIRST arrival gets a real block...
|
||
G.seen.horde = true;
|
||
showModal({ title: 'THEY KEEP COMING', color: UI.hot,
|
||
body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'],
|
||
onDismiss: startHorde });
|
||
} else startHorde(); // ...and every later set starts on the flash alone. Re-showing the
|
||
// same popup at the hottest moment of every set was one of the
|
||
// "popups get me killed" reports — you know the rules by set two.
|
||
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();
|
||
}
|
||
}
|
||
}
|