Splatter gore no longer makes you win a game of tennis first — the mode you pick is the mode you get, with the man you came to play already turned. The transition itself is untouched and still reachable; it just isn't a toll booth. The menu theme could only ever be started, never brought back, so the first run of a session silenced it for good while the court's ambience followed you into the menu. Starting a bed now un-ducks it too. Three world boards instead of one: kills, opponents felled, and games taken off the CPU, each with its own name prompt and its own row per player. ...and the gore economy stops paying a barrel for every game won past game 16, while the shamblers, the mix and the ball machines keep escalating past the point they all used to stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2027 lines
130 KiB
JavaScript
2027 lines
130 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, racketStage, CUP_ROUNDS } from './config.js';
|
||
import { P, O, ball, score, crowd, G, court, tour, 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 { updateDrill } from './drill.js';
|
||
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
|
||
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
|
||
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
|
||
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
|
||
|
||
// ------------------------------------------------------------- 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
|
||
// NO AUTO-AIM IN THE DRILL. This used to lock the shot to the called body wherever you pointed,
|
||
// and Oliver's verdict was the only one it could be: "no skills involved at all" — and with the
|
||
// shot unmissable the fault could never fire either, so the mode had no penalty in it. You aim.
|
||
// What DOES change is the tie-break: aimTie lets a nearer body inside the same cone steal a shot
|
||
// aimed past it, which is right for a crowd you are mowing down and exactly wrong here, where
|
||
// hitting the wrong body IS the failure. Zero slack — the body you are pointing at wins, always.
|
||
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 = G.drill ? 0 : TUNE.aimTie; // the drill never guesses for you — see the note above
|
||
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, aimedLane = false;
|
||
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 {
|
||
// AN AIMED SHOT REACHES ONLY AS FAR AS YOUR TIMING EARNED IT. The singles line is 4.11 m out.
|
||
// This used to aim a flick at a flat 3.3 m and then add err() on top — up to +-2.4 m at quality
|
||
// 0 — so a flicked return with ordinary timing landed as wide as 5.7 m and the only clamp was
|
||
// at 5. An UNAIMED return, targeting 0 + the same scatter, was always in. The flick, the one
|
||
// thing in the mode that is supposed to be skill, was what lost the point: "8/10 times the ball
|
||
// lands out". Reach now scales with the same quality the scatter shrinks with, so the two can
|
||
// never add up past the line — worst case is 1.49 + 2.4 = 3.89 at quality 0, and a perfectly
|
||
// timed flick is the full 3.3 with no scatter left on it at all. Placement is still earned;
|
||
// it just stops being a coin toss you are punished for calling.
|
||
const reach = ch.side === 'P' ? TUNE.flickReach + (1 - TUNE.flickReach) * quality : 1;
|
||
tx = aim.ax * (toFar ? TUNE.flickWidth : 2.7) * reach + err();
|
||
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;
|
||
// A FORWARD FLICK IS A SMASH. Up-screen is the far court, so flicking INTO the court asked for
|
||
// depth and nothing else — the gesture had a direction with no consequence behind it. It buys
|
||
// PACE now, scaled by timing like the width is. Note what it deliberately does not do: solveShot
|
||
// still lands the ball on the same tz, so extra speed makes the shot flatter and earlier rather
|
||
// than longer. A smash you can hit out on a whim would just be the bug above wearing a hat.
|
||
if (ch.side === 'P' && aim.az < 0) s *= 1 + TUNE.smashBoost * quality;
|
||
aimedLane = true;
|
||
}
|
||
tx = Math.max(-5, Math.min(5, tx));
|
||
if (G.match && ch.side !== 'P') {
|
||
// ...AND ITS PACE IS THE FOURTH. Width alone did not fix "unreachable" because the returns left
|
||
// at ~27 m/s (baseShotSpeed + 0.55 quality) — crossing to your baseline in ~0.6 s, which is less
|
||
// time than auto-run needs from the far corner. Opponent one gives you the flight time; the
|
||
// rungs take it back.
|
||
// ...and the RUNG's own pace trait rides on top, inside the cap: full pace is still the ceiling,
|
||
// an opponent just gets there sooner or later than the flat curve would (see matchFoe in config).
|
||
s *= Math.min(1, (TUNE.matchOppPace + (G.match.opp - 1) * TUNE.matchOppPacePer) * (ch.foe?.pace || 1));
|
||
}
|
||
if (G.match) {
|
||
// THE OPPONENT'S WIDTH IS THE THIRD DIAL. Its returns scatter off aimError x (1 - 0.55
|
||
// quality), which paints lines an auto-run player cannot reach — "still a LOT of unreachable
|
||
// balls". Opponent one keeps its returns inside matchWideBase of the centre line; each rung
|
||
// earns matchWidePer more width, so the line-painting is the LATE ladder, not the opening one.
|
||
if (ch.side !== 'P') {
|
||
const wide = (TUNE.matchWideBase + (G.match.opp - 1) * TUNE.matchWidePer) * (ch.foe?.wide || 1);
|
||
tx = Math.max(-wide, Math.min(wide, tx));
|
||
}
|
||
}
|
||
// THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a
|
||
// fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or
|
||
// held direction actually aimed it: an unaimed hit marking its scatter would teach noise.
|
||
// It carries `q` now, so the ring also says HOW MUCH OF THE FLICK LANDED — tight and hot when your
|
||
// timing earned the full reach, wide and dim when it barely applied. Without that the new
|
||
// quality-scaled reach above is invisible: the shot simply goes less wide and nothing says why.
|
||
if (aimedLane && ch.side === 'P' && aim && (aim.ax || aim.az)) {
|
||
G.placeMark = { x: tx, z: tz, t: 0.8, q: quality };
|
||
}
|
||
// 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
|
||
// (the landing ring that used to be stamped here confused testers — the umpire calls the ball,
|
||
// the court stays clean)
|
||
if (ch.side === 'P') 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;
|
||
if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit
|
||
}
|
||
|
||
// ----------------------------------------------------------------- serve ----
|
||
export function tossBall(sv) { // exported for net.js: a remote toss replays as a real toss over O
|
||
ball.active = true; ball.bounces = 0; ball.collected = false;
|
||
// The ball rises over the tossing HAND, not the head — TOSS.dx/oDx are measured off the toss
|
||
// frames' raised fingertips (see config). `* facing` because drawChar mirrors the whole sprite
|
||
// when the server faces left, exactly like the idle bounce's offset.
|
||
ball.x = sv.x + sv.facing * (sv.side === 'P' ? (tour.tossDx ?? TOSS.dx) : TOSS.oDx);
|
||
ball.z = sv.z; ball.y = TOSS.y0;
|
||
ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity;
|
||
// THE TOSS IS THE SERVER'S BALL. lastHit was left stale from the previous rally, and if the
|
||
// OPPONENT had touched it last, the freshly tossed ball read as *their* ball in flight — which
|
||
// armed every rule that judges an opponent ball, most fatally the passing-shot test: the match
|
||
// serves from BEHIND the baseline (z 24.3 > the test's 24.07), so the moment you tossed after any
|
||
// rally they had touched, "its passing shot ends the duel" fired, the point went to them and drew
|
||
// blood. That was Oliver's death loop: every tap = a toss = an instant lost point, faster the
|
||
// more he panicked. One stamp here fixes every caller — the duel and the gore mode carried the
|
||
// same latent bug for anyone tossing from deep behind the line.
|
||
ball.lastHit = sv.side;
|
||
G.phase = 'toss';
|
||
// netplay: the toss travels too, so the receiver watches the serve COMING instead of a ball
|
||
// appearing from nothing — return timing is half the sport and it needs the wind-up
|
||
if (G.net && sv.side === 'P') netSendToss();
|
||
}
|
||
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
|
||
|
||
// how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target
|
||
// sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35).
|
||
const NET_LOFT_TRIES = 12;
|
||
|
||
// ---- ONE SOLVER for the slingshot serve. The reticle draws the arc the ball will actually fly,
|
||
// so the targeting and the ballistics below are shared with hitServe rather than reimplemented.
|
||
// The moment a preview and a shot come from two copies of this maths they drift, and then the
|
||
// preview is worse than nothing — it lies with confidence.
|
||
|
||
// dragServe snaps the ball to a FIXED contact before striking, which is the whole reason the arc
|
||
// is previewable: the launch point is known while you are still pulling. Both live here.
|
||
export const dragContact = () => ({ x: P.x + 0.12, y: 2.2, z: P.z });
|
||
|
||
// Where a splatter serve is aimed. `ex` is the aim error: hitServe passes the real random one,
|
||
// the preview passes 0, so the arc you see is the shot you MEANT rather than the shot you got.
|
||
// Returns null when this is not a free-aim/lane serve at all (the tennis serve keeps its box).
|
||
function splatServeTarget(sv, aim, s, x0, z0, ex = 0) {
|
||
if (G.mode === 'tennis' || G.interlude) return null; // the duel serves like tennis
|
||
const ft = aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) };
|
||
if (ft.zt) {
|
||
const T0 = Math.hypot(ft.zt.x - x0, ft.zt.z - z0) / s;
|
||
// 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, hard = 0) { // hard: the aimed strike's 0..1
|
||
let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + hard * TUNE.serveHard;
|
||
// the harder you hit it, the harder it dips — and the HARD serve dives harder still
|
||
// (serveHardSpin), which is what buys its net clearance: MEASURED with the replica solver,
|
||
// +9 m/s with the old dip was cut BELOW the plain flush by the loft loop (47.2 vs 49.3).
|
||
const g = TUNE.gravity * (1 + quality * TUNE.serveSpin + hard * TUNE.serveHardSpin);
|
||
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';
|
||
// {sx, hard} is the TENNIS strike aim (see keyPressed's toss branch): sx sways the target in
|
||
// SCREEN direction — flick right, serve right — and hard buys pace and depth, paid for in
|
||
// scatter. Nothing clamps the swayed target back inside the lines: aiming for the sideline
|
||
// with a mistimed toss is how a real first serve goes long, and faults already have their
|
||
// machinery (serveNum, the line call). A plain tap keeps the old safe centre-box serve.
|
||
const sx = aim && aim.sx !== undefined ? aim.sx : 0;
|
||
const hard = (aim && aim.hard) || 0;
|
||
const pace = hard * TUNE.serveHard;
|
||
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed + pace;
|
||
// 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.
|
||
const scatter = (0.8 + hard * TUNE.serveHardErr) * (1 - quality);
|
||
// THE AIM BASE NEVER LEAVES THE BOX. A full sway used to carry the target ACROSS the centre
|
||
// line — an aimed serve was a fault by construction, not by risk. The sway now runs from the
|
||
// box centre to 0.18 m INSIDE either line, and only the timing scatter (added after) can
|
||
// carry the ball out — which is the tennis being asked for: the line is where you aim, the
|
||
// miss is what you risked. (Classic enforces the box; see fault()'s caller.)
|
||
const side = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1);
|
||
const half = COURT.W / 2;
|
||
const aim = side * 2.0 + sx * TUNE.serveAimX;
|
||
const lo = side < 0 ? -half + 0.18 : 0.18, hi = side < 0 ? -0.18 : half - 0.18;
|
||
boxX = Math.max(lo, Math.min(hi, aim))
|
||
+ (Math.random() * 2 - 1) * TUNE.aimError * scatter;
|
||
// DEPTH RIDES QUALITY (player serves only): a flush strike aims near the service line, the
|
||
// ONLY depth where flat pace survives the net-clearance loop — MEASURED with the replica
|
||
// solver: at the old fixed mid-box depth every serve above q≈0.7 was cut to the same
|
||
// ~36 m/s, which is the mechanical root of "hitting below the golden mark does not
|
||
// necessarily make a worse serve". The ladder now reads flush 49 / flush+hard 52 /
|
||
// mistimed 36 / bad 30. The 0.3 m floor inside the line is what scatter faults against.
|
||
// The opponent keeps its fixed tuned depth — its ladder is dialled elsewhere.
|
||
const boxZ = toFar ? COURT.svcFar + Math.max(0.3, 1.6 - quality * 1.35 - hard * 0.5)
|
||
: COURT.svcNear - 1.6;
|
||
tzs = boxZ + (Math.random() * 2 - 1) * scatter * 1.5;
|
||
}
|
||
const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality, st ? st.ty : 0.08, hard);
|
||
// 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)
|
||
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 (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();
|
||
}
|
||
if (G.net && sv.side === 'P') netSendShot(); // netplay: a serve is a shot event like any other
|
||
}
|
||
|
||
// ---------------------------------------------------------------- scoring ----
|
||
export 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) {
|
||
// DANIEL IS BENCHED (2026-08-13). Oliver: "daniel is very repetetive. please let him just
|
||
// announce the normal tennis game events for now.. this is a task for another time." So the
|
||
// umpire keeps every OFFICIATING call (score, game/set, fault, out, let, time, play) and loses
|
||
// ALL personality asides — this early return is the whole cut, since every ump_* remark routes
|
||
// through here. The situation triggers and the cooldown model below survive intact for the
|
||
// personality pass that will re-open him; do not delete them.
|
||
return;
|
||
// THE MATCH IS THE EXCEPTION to the interlude bar, exactly as sendBoy is: the match runs on a
|
||
// PERMANENT interlude, so this line silently muted the umpire for an entire mode — the one mode
|
||
// that is literally a tennis match he is sitting over. The shared asideEvery cooldown still holds,
|
||
// which is the rule that keeps him situational rather than chatty ("too much and not situation
|
||
// aware.. they seem random"). Cards still block him: a name plate is already the mode talking.
|
||
if (G.mode !== 'splatter' || G.modal || G.changeover || G.card) return;
|
||
if (G.interlude && !G.match) return;
|
||
if (G.asideT > 0) return;
|
||
G.asideT = G.match ? TUNE.matchAsideEvery : 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);
|
||
}
|
||
|
||
// Games between weapon barrels. Exported for the suite, because the shape of this curve IS the
|
||
// gore mode's economy and it is one line of arithmetic that once quietly became "one per game".
|
||
// The floor is the point: however long a run gets, a barrel stays something you waited for.
|
||
export const barrelGap = () => Math.max(TUNE.barrelFloor,
|
||
TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp));
|
||
// ...and how many ball machines the late court is allowed to hold at once. Same idea from the other
|
||
// end: the barrels stop growing, this one starts.
|
||
export const machCap = () => Math.min(TUNE.machMaxCap,
|
||
TUNE.machMax + Math.floor(G.runGames / TUNE.machMaxPer));
|
||
|
||
export 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;
|
||
}
|
||
|
||
// A DECIDED FLIGHT. Everywhere but netplay the ball stops dead where it always has; over the wire
|
||
// it keeps its physics and rolls to a natural halt, because the striker's phone is a whole court
|
||
// and a latency away from the call and freezing its ball mid-roll read as the game loop stopping
|
||
// (Oliver, 2026-08-15). `ball.scored` is what switches off every scoring consequence downstream.
|
||
const endFlight = () => { if (G.net) ball.scored = true; else ball.active = false; };
|
||
|
||
export function pointTo(side, reason) { // exported for net.js: a remote verdict lands here
|
||
// netplay: this phone judged the live flight — the verdict goes over the wire before it is
|
||
// applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE
|
||
// verdict is being applied, which is what stops the two phones echoing a point back and forth.
|
||
if (netJudging()) netSendPoint(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) {
|
||
// ...unless this is CLASSIC TENNIS, where nothing turns and the first game you win is just the
|
||
// first game you win. That check is the whole of the mode's isolation from the gore path.
|
||
if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned && !G.classic) return startTransition();
|
||
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
|
||
cheer(2.8, 0.6);
|
||
score.server = other(score.server);
|
||
if (G.classic) {
|
||
G.runGames++;
|
||
if (side === 'P') G.classicWon++; // the number its board ranks
|
||
if (classicGame(side)) return; // ...then a set, then the match
|
||
}
|
||
} else {
|
||
voice(scoreKey(), 0.7);
|
||
if (side === 'P') cheer(1.2, 0.3);
|
||
}
|
||
G.serveNum = 1;
|
||
}
|
||
|
||
// CLASSIC TENNIS: a game has just been banked by advancePoint — is it also a set, and is the set
|
||
// also the match? Returns true when it ended the match, so pointTo stops handing the point flow on.
|
||
// Six games AND a two-game lead, the real rule; no tiebreak, so 6-6 simply plays on to 8-6. That is
|
||
// the one simplification here and it is deliberate — a tiebreak is a different scoring mode bolted
|
||
// onto a mode that is not yet proven, and 7-5 sets are not what anybody is complaining about.
|
||
// ponytail: add the tiebreak when a real match actually stalls at 6-6.
|
||
// Exported for the suite: the whole of a mode's scoring rule is worth pinning down, and the only
|
||
// other way in is winning twelve games through real rallies.
|
||
export function classicGame(side) {
|
||
const i = side === 'P' ? 0 : 1, j = 1 - i;
|
||
if (score.games[i] < 6 || score.games[i] - score.games[j] < 2) return false;
|
||
G.sets[i]++; score.games = [0, 0];
|
||
const mine = i === 0;
|
||
if (G.sets[i] >= TUNE.classicSets) { endClassic(mine); return true; }
|
||
G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), sub1: mine ? 'THE SET IS YOURS' : 'SET TO THEM',
|
||
color: mine ? UI.pick : UI.hot, t: TUNE.setCardT };
|
||
G.overT = Math.max(G.overT, TUNE.setCardT);
|
||
return false;
|
||
}
|
||
|
||
// The match is over and NOBODY DIED — the one end-of-run state that is not a corpse. It reuses the
|
||
// death screen's whole shape (a held summary, then a gated tap that restarts) because that is the
|
||
// only thing about the two endings that is the same; the words, the colour and the still-standing
|
||
// player are all its own. update() freezes on G.done, exactly as it does on G.mode === 'eaten'.
|
||
export function endClassic(won) {
|
||
if (G.done) return;
|
||
G.done = { won, t: 0 };
|
||
ball.active = false; G.flash = null; G.modal = null; G.card = null;
|
||
if (won) { cheer(4, 1); applause(1, 0.2); voice('unbelievable', 0.4); }
|
||
else { aww(1); voice('game_opponent', 0.3); }
|
||
// g = GAMES PLAYED (what the stats screen calls LONGEST MATCH), s = GAMES YOU WON — this mode's
|
||
// two headline numbers in the record's two slots. `s` was SETS until the classic board arrived,
|
||
// and nothing ever displayed it: the sets line on the death screen is gore's, and read() has
|
||
// never looked at `s` at all. Games won is the number worth ranking, because every classic win is
|
||
// two sets and a board of nothing but 2s is a wall of ties.
|
||
// `dead` carries the RESULT: a match you lost is the run ending badly, a match you won is not,
|
||
// so the stats screen's DEATHS column reads as matches lost and (runs − deaths) as matches won,
|
||
// with no extra field to keep in step.
|
||
const b = endRun(G.runGames, G.classicWon, G.splatT, !won, MODE_CLASSIC);
|
||
G.best = b; G.newBest = b.newBest;
|
||
}
|
||
|
||
// 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 (G.drill) return; // the drill keeps its own round count — see drill.js. Routing it through here
|
||
// would arm the barrel/machine/boss schedule inside a mode that has no wave.
|
||
// The duel is one clean tennis point and resolveInterlude is its ONLY exit. A machine or leftover
|
||
// killed mid-duel used to score here, and a game banked mid-duel re-entered proceedAfterClear —
|
||
// which can start a boss, a card, or a horde while the duel still owns the court.
|
||
if (G.interlude) return;
|
||
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 < machCap() && !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 % barrelGap() === 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);
|
||
}
|
||
}
|
||
|
||
// (proceedAfterClear and every start*/end* mode transition live in beats.js — one file owns who
|
||
// has the court. splatPoint above only ARMS the schedule latches; beats.js decides when they fire.)
|
||
|
||
// ---- 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
|
||
|
||
// (startDuel, startBossFight/endBossFight, the deadlock watchdog and resolveInterlude live in
|
||
// beats.js — see its header for the one-owner rule.)
|
||
|
||
// 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);
|
||
|
||
// (startHorde lives in beats.js)
|
||
|
||
// 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() {
|
||
netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried
|
||
resetZombies(); resetInput(); resetState(); setupPoint();
|
||
beginRun(); // ...and the stats bag, which is the only state that does not live on G
|
||
}
|
||
|
||
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, () => {
|
||
enterSplatter();
|
||
G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match
|
||
G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 };
|
||
});
|
||
}
|
||
|
||
// WHERE THE SPLATTER MODE ACTUALLY BEGINS. Shared by the transition's last beat and by the gore
|
||
// mode's own start, so the two doors cannot land the player in two slightly different games.
|
||
// NO modal here — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches the pull
|
||
// instead: the idle clock is primed so the apron demo starts miming IMMEDIATELY rather than after
|
||
// the usual beat of inaction, and it keeps coming back whenever the player stalls.
|
||
function enterSplatter() {
|
||
G.mode = 'splatter'; G.everTransitioned = true;
|
||
score.server = 'P'; setupPoint();
|
||
voice('play', 0.4, 0.9); zombieMusic();
|
||
G.idleHintT = TUNE.pullHintAfter;
|
||
}
|
||
|
||
// THE GORE MODE STARTS IN THE GORE (Oliver, 2026-08-15: "the splatter gore should not have the
|
||
// normal tennis intro anymore, we should just start right in splatter mode"). The prologue was a
|
||
// game of real tennis you had to win before the mode you picked would begin — fine as a reveal the
|
||
// first time, a toll booth every time after it. The transition itself is NOT deleted: it is still
|
||
// what `forceZombies` plays, it is still the cinematic, and `everTransitioned` above keeps it from
|
||
// firing behind us. What is gone is it standing between the menu and the mode.
|
||
// The turned opponent still opens the run — he is the man you came to play, and the mode's whole
|
||
// premise is that he is already one of them.
|
||
export function startGore() {
|
||
if (G.mode !== 'tennis') return;
|
||
enterSplatter();
|
||
spawnZombie(O.x, O.z, 'c'); G.hideO = true;
|
||
G.shake = { t: 0.6, mag: 7 };
|
||
G.flash = { text: 'THE CLUB IS DEAD', t: 2.0 };
|
||
}
|
||
|
||
export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve
|
||
if (G.serveNum === 1) {
|
||
if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double
|
||
// fault below goes out through pointTo as the point it is
|
||
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;
|
||
}
|
||
|
||
// Would winning THIS point put a collapse (or the game-winning kill) on court? Mirrors
|
||
// matchPoint's own wound math and advancePoint's game test — if the answer is no, there is
|
||
// nothing for an early resolution to show, and the rally keeps its ordinary ending.
|
||
function matchFallWillPlay() {
|
||
const rz = zombies.find(z => z.variant === 'r');
|
||
if (!rz) return false;
|
||
const cup = G.match.cup;
|
||
const gameWin = score.pts[0] + 1 >= 4 && score.pts[0] + 1 - score.pts[1] >= 2;
|
||
// a game win is only THE KILL when it ends the round — a cup mid-round game keeps the body
|
||
if (gameWin) return !cup || cup.g + 1 >= CUP_ROUNDS[cup.round].need;
|
||
// the same divisor matchPoint will use — the first version kept the deathmatch /4 and in the
|
||
// cup it promised falls that matchPoint then didn't deliver (a route to the stale-latch lock)
|
||
const div = 4 * ((cup && CUP_ROUNDS[cup.round].need) || 1);
|
||
const after = Math.max(1, rz.hp - Math.ceil(rz.hpMax / div));
|
||
return racketStage({ hp: after, hpMax: rz.hpMax }) !== racketStage(rz); // a limb
|
||
}
|
||
|
||
const rzChaseSpeed = rz => TUNE.racketSpeed
|
||
* (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1);
|
||
|
||
// Can the match opponent still reach this ball before its second bounce? Closed-form flight from
|
||
// the POST-BOUNCE state (velocities already damped when the line call runs) against the body's own
|
||
// chase speed — the same dials racketAI moves with — plus a full swing reach and a 0.6 m benefit
|
||
// of the doubt. Returns true only when the rally is provably over.
|
||
function matchOutOfReach() {
|
||
const rz = zombies.find(z => z.variant === 'r');
|
||
if (!rz || ball.vz > 0) return false;
|
||
const g = ball.g || TUNE.gravity;
|
||
const t2 = Math.max(0.05, 2 * ball.vy / g); // time until the second touchdown
|
||
const spd = rzChaseSpeed(rz);
|
||
for (let i = 0; i <= 8; i++) {
|
||
const t = t2 * i / 8;
|
||
const bx = ball.x + ball.vx * t, bz = ball.z + ball.vz * t;
|
||
if (Math.hypot(bx - rz.x, bz - rz.z) <= spd * t + TUNE.aiReach + 0.6) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// The MID-FLIGHT version (Oliver: "the losing point could still be more anticipated"): the same
|
||
// question asked while the ball is still in the air, over the piecewise track flight -> bounce ->
|
||
// roll to the second touchdown. Same conservative margins; pred is predictLanding()'s exact spot.
|
||
function matchOutOfReachFlight(rz, pred) {
|
||
const g = ball.g || TUNE.gravity;
|
||
const spd = rzChaseSpeed(rz);
|
||
const vyl = ball.vy - g * pred.t; // vertical speed at touchdown
|
||
const t2 = Math.max(0.05, 2 * (-vyl * TUNE.bounceY) / g);
|
||
const vx2 = ball.vx * TUNE.bounceXZ, vz2 = ball.vz * TUNE.bounceXZ;
|
||
const T = pred.t + t2;
|
||
for (let i = 0; i <= 10; i++) {
|
||
const t = T * i / 10;
|
||
const bx = t <= pred.t ? ball.x + ball.vx * t : pred.x + vx2 * (t - pred.t);
|
||
const bz = t <= pred.t ? ball.z + ball.vz * t : pred.z + vz2 * (t - pred.t);
|
||
if (Math.hypot(bx - rz.x, bz - rz.z) <= spd * t + TUNE.aiReach + 0.6) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function onBounce() {
|
||
ball.bounces++;
|
||
pock(0.35, 500);
|
||
if (ball.scored) return; // a called ball still bounces and still sounds; it just cannot score twice
|
||
// 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; }
|
||
// NETPLAY (death match): same rule as the tennis block below — the receiving phone is the only
|
||
// judge of a flight; our outbound ball is picture and its verdict arrives as a message.
|
||
if (G.net && ball.lastHit === 'P') 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) { endFlight(); resolveInterlude(other(ball.lastHit)); voice('out'); }
|
||
// THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know
|
||
// this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just
|
||
// bounced in and is provably beyond the body's reach has already decided the point, so it
|
||
// resolves HERE: the collapse starts while the ball still rolls past, and the picture reads
|
||
// "he fell, so it got by". Two deliberate limits:
|
||
// - only when a FALL (or the game-winning kill) will actually play — an ordinary point
|
||
// keeps its ordinary bounce-2 ending, or the ball would vanish mid-roll for nothing;
|
||
// - conservative reach test — a borderline ball waits for the ordinary call.
|
||
// THE LATCH IS SET BEFORE THE RESOLVE AND CLEARED WHEREVER A RALLY ARMS. Version one set
|
||
// it after and a no-fall point's synchronous matchReady left it stale (the first lock);
|
||
// version two argued after-set safe because "every admitted path defers matchReady" — and
|
||
// the cup's mid-round game win promptly broke the argument (the second lock, "like freeze
|
||
// from before"). No more ordering proofs: the latch goes up BEFORE, the resolution in
|
||
// progress is exempted via G.matchEarly in resolveInterlude's swallow, and any path that
|
||
// re-arms play clears it in matchReady. Stale by construction impossible.
|
||
else if (G.match && !G.net && !G.match.ending && !G.match.decided && ball.lastHit === 'P'
|
||
// !G.net: the early decide predicts the CPU's legs — a human on another phone is
|
||
// not predictable, and the receiver is the only judge of this flight anyway
|
||
&& matchFallWillPlay() && matchOutOfReach()) {
|
||
G.match.decided = true; // scope: the REMAINING ball events of this rally
|
||
G.matchEarly = true; // startFall leaves the ball rolling; the swallow exempts us
|
||
resolveInterlude('P');
|
||
G.matchEarly = false;
|
||
}
|
||
} else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit); }
|
||
return;
|
||
}
|
||
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
|
||
// NETPLAY: THE RECEIVING PHONE IS THE ONLY JUDGE OF A FLIGHT. Our outbound ball is picture, not
|
||
// evidence — the two sims are ~a latency apart, and this one would call a double bounce before
|
||
// the other one's player had swung. Every call on this ball (box, line, second bounce) arrives
|
||
// as a message instead; the ball rolls on harmlessly until it does. (See net.js.)
|
||
if (G.net && ball.lastHit === 'P') return;
|
||
if (ball.bounces === 1) {
|
||
const wasServe = G.wasServe; G.wasServe = false;
|
||
if (wasServe) {
|
||
if (!inServeBox(ball.x, ball.z)) { endFlight(); 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) {
|
||
endFlight(); pointTo(other(ball.lastHit), 'out');
|
||
}
|
||
} else if (ball.bounces === 2) {
|
||
endFlight(); 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.
|
||
// The assist's leg speed — the match floors it (matchAssist) because autoAssist is 0 wherever the
|
||
// pointer is fine. Shared by the movement block AND intercept below: intercept uses it to decide
|
||
// which points are reachable, and computing that with autoAssist alone meant a desktop match's
|
||
// intercept thought NOTHING was reachable and sent the player home every time — he had legs but
|
||
// his destination was always the baseline centre.
|
||
// THE RALLY MODES SHARE ONE PLAYER FEEL (Oliver, 2026-08-14: "in classic tennis I tend to miss
|
||
// balls way more often — zombie tennis is the good one"). The match's three contact helpers —
|
||
// assist floor, matchReach, the short-ball chase — were tuned in the match and classic never got
|
||
// them, so the same swing whiffed centimetres short there. Gore's opener keeps the old feel: its
|
||
// tennis is a prologue, not the mode.
|
||
export const rallyFeel = () => !!(G.match || G.classic);
|
||
export const assistK = () => rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist;
|
||
|
||
function intercept(side) {
|
||
const ch = side === 'O' ? O : P;
|
||
// G.diff is CLASSIC's difficulty chip (config.DIFFS) — null everywhere else, so only the
|
||
// classic CPU speeds up or slows down. Same guard on every dial it scales.
|
||
const spd = side === 'O' ? TUNE.aiSpeed * (G.diff ? G.diff.speed : 1) : TUNE.playerSpeed * assistK();
|
||
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, anyHit = 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; }
|
||
// THE SHORT BALL (match): a slow return can bounce and DIE at z 18-20, which never enters the
|
||
// near zone below — so nothing ever chased it, it rolled dead at the player's feet (below the
|
||
// legal contact height by then) and the point went to the zombie. Seven of seven recorded
|
||
// losses. Remember the earliest reachable point ON HIS HALF where the ball is still at hitting
|
||
// height: running forward onto the short ball is the tennis answer, and now it is the assist's.
|
||
if (side === 'P' && rallyFeel() && !anyHit && i > 5 && z > COURT.netZ && y < 1.6 && y > 0.35) {
|
||
const pt = { x, z: Math.min(24.5, z) };
|
||
if (Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt) anyHit = pt;
|
||
}
|
||
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 || anyHit || home; // zone points first: meeting deep is still better
|
||
}
|
||
|
||
// ---------------------------------------------------------- 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;
|
||
|
||
export function logSwing(entry) { // exported for beats.js (the watchdog logs into the same ring)
|
||
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;
|
||
// The MATCH plays with a longer racket. The flight recorder's third session: he ran onto every
|
||
// ball (2.9-3.9 m per point) and swung every time, and the closest approach clustered at a
|
||
// sampled 1.3-1.6 m — centimetres outside the 1.3 m post-bounce reach, point after point. The
|
||
// assist can deliver you NEAR the ball but not INSIDE a phone-tuned reach; a rally mode lives or
|
||
// dies on contact feeling fair, and the gore modes are untouched.
|
||
const reach = isAI ? (G.diff ? G.diff.reach : TUNE.aiReach)
|
||
: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 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();
|
||
// SMART AIM (G.diff.smart, classic difficulty): with this probability the CPU plays the OPEN
|
||
// COURT — the corner away from where you stand — instead of rolling a random lane. Random aim
|
||
// sends a third of everything straight back to your racket; hunting the open corner is what
|
||
// makes a hard CPU feel like it is PLAYING you. ax is an absolute court lane (see doHit).
|
||
const smart = isAI && G.diff && Math.random() < (G.diff.smart || 0);
|
||
const aim = isAI
|
||
? { ax: smart ? (P.x > 0.6 ? -1 : P.x < -0.6 ? 1 : (Math.random() < 0.5 ? -1 : 1))
|
||
: [ -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) };
|
||
// G.diff (classic difficulty) moves the FLOOR too: a raised qMin is what makes HARD's every
|
||
// ball arrive with pace, where a cap alone just trims the best ones. Null outside classic, so
|
||
// the racket zombie and the gore opener keep the TUNE floor.
|
||
const qMin = G.diff ? G.diff.qMin : TUNE.aiQualityMin;
|
||
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
|
||
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) {
|
||
// THE MATCH ESCALATES HERE, not in hp: with regicide gone the pool is pure scoreboard,
|
||
// so a harder opponent is a FASTER one that flubs less. The BASE is boosted too — the duel's
|
||
// 2.8 m/s lumber was tuned for one sudden-death point against a fresh player, and across a
|
||
// whole game it read as "very weak and slow": opponent one already moves at matchSpeedBase.
|
||
const spd = TUNE.racketSpeed * (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer)
|
||
* (rz.foe?.speed || 1) : 1);
|
||
const step = Math.min(spd * 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';
|
||
// `face`, NOT `facing`. Zombies render off zb.face (drawZombie); `facing` is the PLAYER's field
|
||
// (render flips a char on facing < 0 — the opposite convention, because the player art is drawn
|
||
// facing RIGHT and the bodies are drawn facing LEFT). This line wrote a field nothing reads for a
|
||
// zombie, so the match opponent's turn was left to the generic sticky-face line in updateZombies.
|
||
// vx is kept so a body that is knocked down mid-chase can carry the run into its collapse.
|
||
earnFlip(rz, 'face', Math.abs(dx) > 0.2 ? (dx > 0 ? 1 : -1) : 0, dt);
|
||
rz.vx = dx / d * step / dt;
|
||
} else { rz.vx = 0; 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) {
|
||
// ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away,
|
||
// which across a game is an opponent that beats itself.
|
||
const flub = G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1)
|
||
: TUNE.racketFlub;
|
||
if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
|
||
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) {
|
||
// THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no
|
||
// ball and no scoring, so every other meaning a key has here is meaningless inside it.
|
||
if (G.bench) return benchKey(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 && !G.drill) { // no swish in the drill: it cleaves whatever
|
||
// is adjacent, which is the mode picking
|
||
// your targets for you
|
||
// 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
|
||
if (G.net && (!G.net.ready || G.net.dead)) return; // netplay: no serving into an empty room
|
||
// 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. The snap window
|
||
// and falloff are TUNE knobs now (apexSnap/apexFall) and deliberately tight: the old 0.12 m
|
||
// snap held PERFECT for ~0.3 s of hang time, which is why the gold mark felt free.
|
||
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 / TUNE.apexFall);
|
||
if (dy < TUNE.apexSnap) q = 1;
|
||
G.wasServe = true;
|
||
const flick = takeFlickAim();
|
||
if (G.mode === 'splatter' && !G.interlude) {
|
||
// splatter: a flick at the apex is the aimed free-aim serve, exactly as before
|
||
const kb = { ax: axisX(), az: axisZ() };
|
||
hitServe(P, q, flick || (kb.ax || kb.az ? kb : null));
|
||
return;
|
||
}
|
||
// TENNIS AND THE DUEL: the strike gesture IS the aim (Oliver: "the flicking does not work
|
||
// here - also i see no option to serve harder"). Flick as you strike: the HORIZONTAL
|
||
// component sways the serve toward the T or the sideline, the UPWARD component is pace —
|
||
// swing through the ball to hit it harder. Both scale with the flick's length (fx/fy are
|
||
// unit components; mag carries the length). Keyboard: left/right sway, up = the hard one.
|
||
// A plain tap stays the safe centre-box serve — that is the second-serve decision.
|
||
const kb = { ax: axisX(), az: axisZ() };
|
||
// the dead zone is 44 px, well above the 28 px flick threshold: a slightly smudged strike TAP
|
||
// must stay a safe serve — accidental sway was most of classic's "double fault very often",
|
||
// because classic's line call enforces the service box (the match's only checks the court)
|
||
const k = flick ? Math.max(0, Math.min(1, (flick.mag - 44) / 130)) : 0;
|
||
const sway = flick ? flick.fx * k : kb.ax * 0.75;
|
||
const hard = flick ? Math.max(0, -flick.fy) * k : (kb.az < 0 ? 0.8 : 0);
|
||
hitServe(P, q, sway || hard ? { sx: sway, hard } : 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'; }
|
||
}
|
||
|
||
// A FACING FLIP MUST BE EARNED. The rolled run cycles carry a real racket, and a mirror flip
|
||
// teleports it across the body — so the per-frame steering flutter the old racketless art could
|
||
// hide reads as "the racket she is holding flashing left and right very fast" (Oliver, women's
|
||
// tour, where the drawn racket is biggest). A flip now needs the wanted direction to PERSIST for
|
||
// TUNE.faceFlip seconds. Every auto-steered body routes through here (the player's assist, the
|
||
// tennis CPU, the match opponent); MANUAL input stays instant — a thumb reversal is intent.
|
||
function earnFlip(ch, field, want, dt) {
|
||
if (!want || want === ch[field]) { ch.flipT = 0; return; }
|
||
if ((ch.flipT = (ch.flipT || 0) + dt) >= TUNE.faceFlip) { ch[field] = want; ch.flipT = 0; }
|
||
}
|
||
|
||
// ------------------------------------------------------------- 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) {
|
||
// faded, or up the far end where he can't work — EXCEPT in the match, where the far end is
|
||
// exactly where every opponent dies: the mop beat holds an empty court for him, so the
|
||
// gore-mode reach limit would leave him jobless two steps from the mess he was sent for
|
||
if (d.a < 0.15 || (!G.match && d.z < COURT.netZ - 2)) continue;
|
||
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);
|
||
// during the match's mop beat he HURRIES between stains (the beat is his stage and it is 5 s
|
||
// long; at walking pace he spent 4 of them getting into shot) — the push itself keeps its pace
|
||
const spd = G.match && G.match.mopT > 0 ? TUNE.boyHurry : TUNE.boyMopSpeed;
|
||
const step = Math.min(spd * 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; }
|
||
// THE OTHER ENDING: a classic match played out. The world holds still — the man is standing, the
|
||
// crowd is still clapping it down — and the only thing running is the clock the summary and its
|
||
// restart tap are gated on. Same shape as the block below, without a corpse in it.
|
||
if (G.done) {
|
||
G.idleT += dt; G.done.t += dt;
|
||
if (G.done.t >= EATEN_SUMMARY && !G.done.handed) { G.done.handed = true; menuDuck(false); }
|
||
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||
if (G.card && (G.card.t -= dt) <= 0) G.card = null;
|
||
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) {
|
||
if ((m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud
|
||
}
|
||
// ...and a modal may carry its OWN clock (`hold`): it announces, holds the frozen court for a
|
||
// couple of seconds and hands it back without being tapped. The boss walk-on is the case — an
|
||
// announcement the player must be PREPARED by, where waiting for a tap would be the control
|
||
// steal the popup policy forbids. Only ticks once it has landed; a tap still dismisses early.
|
||
else if (m.hold > 0 && (m.hold -= dt) <= 0) dismissModal();
|
||
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.placeMark && (G.placeMark.t -= dt) <= 0) G.placeMark = null; // the placement ring fades
|
||
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.
|
||
// ...and a live BURST is the exception, for the same reason the horde forces 'rally': it is an
|
||
// auto-fire control mode that owns the finger for its whole window, and the beats that fire when
|
||
// it clears the court (a point, a won game, the mop-up) park the phase in 'over'. Gating the
|
||
// steer on the phase froze the player mid-burst the moment he was winning. The settled "no
|
||
// walking between points" rule is untouched — nothing arms a burst except the player.
|
||
if (G.phase === 'rally' || G.mgT > 0) {
|
||
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;
|
||
// THE MATCH BRINGS ITS OWN LEGS, on every device. autoAssist keys off pointer:coarse, so the
|
||
// desktop Browser pane (pointer: fine) had NO auto-run at all — the flight recorder showed
|
||
// Oliver swinging in every lost point while his x never moved: a statue with a racket. The
|
||
// match is a tap-timing game; footwork is the game's job. Held keys still override (mx/mz).
|
||
const aK = assistK();
|
||
if (!mx && !mz && aK > 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 * aK * dt * (chasing ? 1 : 0.55);
|
||
// Run to the ball — then recover. WHERE you recover to is mode-specific and each rule exists
|
||
// for a reason:
|
||
// GORE SPLATTER recovers in DEPTH ONLY, holding your lane — recovering to HOME.P's x undid
|
||
// the sidestep (measured: a step to 1.94 m dragged back to 0.77 m over one ball's flight),
|
||
// which is the "welded in place" complaint the sidestep answers, arriving slowly.
|
||
// TENNIS and THE MATCH recover to CENTRE — that is the real sport's footwork, and in a
|
||
// rally there is no artillery lane to protect. The match inherited the gore rule by being
|
||
// G.mode 'splatter', and the cost was exactly Oliver's "every second ball is basically
|
||
// unreachable": one wide return pulled you off centre and you STAYED there, so the next
|
||
// ball to the opposite corner was lost before it was struck. Not a strong CPU — a player
|
||
// who never walked back.
|
||
const tgt = chasing ? intercept('P')
|
||
: G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z }
|
||
: HOME.P;
|
||
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; P.flipT = 0; } // manual: instant, and it resets the earn clock
|
||
else earnFlip(P, 'facing', Math.abs(assist) > 0.25 ? (assist > 0 ? 1 : -1) : 0, dt);
|
||
}
|
||
// ...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);
|
||
else updateParts(dt);
|
||
updateBench(dt); // the sprite bench's pacing body — a no-op unless the back room opened it
|
||
updateDrill(dt); // the coaching drill's round clock. It MOVES its bodies (they carry v = 0, so
|
||
// updateZombies' shamble skips them) and updateZombies still runs the contact
|
||
// and ball-hit tests against wherever this put them — that split is the design. // tennis has no zombies but its bounces still kick up dust that must settle
|
||
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.net) netTick(dt); // netplay: the 10 Hz position stream out, the opponent's body in
|
||
// !G.horde: a horde mows the court down to an EMPTY FRAME between spawns, and a clear latched
|
||
// before the wave began (a game won during the set card) then advanced the beat mid-horde —
|
||
// which is how a sudden-death duel once started INSIDE a horde. The clear waits for endHorde.
|
||
if (G.clearing && !G.horde && !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)
|
||
// THE MATCH SERVES TOO. This was gated on `G.mode === 'tennis'` and the match runs in splatter
|
||
// under a permanent interlude, so the corpse could only ever RETURN — return timing, half the
|
||
// sport, was never asked of the player. setupPoint already places a non-player server behind its
|
||
// own baseline and arms aiServeT; this is the one condition that was keeping it switched off.
|
||
// The intro beat holds it: a body still walking on must not open with a serve.
|
||
const oServes = (G.mode === 'tennis' || G.match) && score.server === 'O'
|
||
&& !(G.interlude && G.interlude.introT > 0)
|
||
&& !G.net; // netplay: the far side serves on the OTHER phone — the serve arrives as a shot event
|
||
if (oServes && G.phase === 'pretoss') {
|
||
G.aiServeT -= dt;
|
||
if (G.aiServeT <= 0) tossBall(O);
|
||
} else if (oServes && G.phase === 'toss') {
|
||
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) {
|
||
G.wasServe = true; hitServe(O, Math.min(1, (0.6 + Math.random() * 0.35) * (G.diff ? G.diff.serve : 1)));
|
||
// ...and the body that just served holds the strike, so the swing reads as its own (beats)
|
||
if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; }
|
||
}
|
||
}
|
||
if (G.mode === 'tennis' && G.phase === 'rally' && !G.net) { // netplay: O is a real person on
|
||
// another phone — netTick drives the body
|
||
G.aiReactT += dt;
|
||
const incoming = ball.active && ball.lastHit === 'P'
|
||
&& G.aiReactT >= (G.diff ? G.diff.reaction : TUNE.aiReaction);
|
||
// THE INTERCEPT GETS A DEADBAND (Oliver: "switching between a left and right movement
|
||
// increasingly faster the nearer the ball comes"). intercept() re-solves every frame, and as
|
||
// the flight shortens the earliest-reachable point jumps around its solution — each jump
|
||
// flips his run direction, faster and faster. He commits to the stored point unless the
|
||
// fresh solve moves it meaningfully (or the chase resets between shots).
|
||
const fresh = incoming ? intercept('O') : HOME.O;
|
||
if (!incoming || !G.aiTgt || Math.hypot(fresh.x - G.aiTgt.x, fresh.z - G.aiTgt.z) > 0.35)
|
||
G.aiTgt = fresh;
|
||
const tgt = G.aiTgt;
|
||
const spd = TUNE.aiSpeed * (G.diff ? G.diff.speed : 1)
|
||
* (incoming ? 1 : (G.diff ? G.diff.recover : 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;
|
||
earnFlip(O, 'facing', Math.abs(dx) > 0.25 ? (dx > 0 ? 1 : -1) : 0, dt);
|
||
} 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).
|
||
// CLASSIC TENNIS turns the band OFF and flubs less: the band exists to get a new player
|
||
// PAST the opener, and a mode whose whole promise is a real match cannot be won by an
|
||
// opponent that falls apart the moment he goes ahead.
|
||
const flub = G.classic ? (G.diff ? G.diff.flub : TUNE.classicFlub)
|
||
: TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss;
|
||
if (Math.random() < flub) G.aiSwingCool = 0.5; // whiff: hesitates and misses
|
||
else {
|
||
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
|
||
trySwing(O, true, G.classic ? (G.diff ? G.diff.quality : TUNE.classicQualityMax)
|
||
: TUNE.aiQualityMax); 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)
|
||
// ...and not while the body is on the ground: the fall owns the sprite AND the pose for its
|
||
// whole run, and racketAI would put a collapsing corpse back on its feet to chase a stray ball.
|
||
if (!G.bench && !G.matchFall && !G.net) for (const rz of zombies) if (rz.variant === "r") racketAI(rz, dt); // the bench drives its own body; netplay's body is the other phone (netTick)
|
||
|
||
// --- 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
|
||
}
|
||
}
|
||
// THE FALL ANTICIPATES THE PASS (Oliver: "the losing point could still be more anticipated
|
||
// timing-wise"). Once the player's winner is past the net its landing is closed-form knowable
|
||
// (predictLanding is exact), so a ball that will bounce in and is provably beyond the body's
|
||
// reach resolves MID-FLIGHT — the collapse starts while the ball is still in the air. The
|
||
// bounce-1 hook in onBounce stays as the fallback for anything too close to call up here.
|
||
// !G.net for the same reason its bounce-1 sibling carries it, and MISSING IT WAS THE DEATHMATCH
|
||
// DESYNC ("both players stand on the same side and nobody can serve", cellular): this predicts
|
||
// the CPU's legs, and a human on another phone is not predictable. Worse, it made the STRIKER a
|
||
// judge of its own outbound flight — resolveInterlude sends the verdict — so on a slow link both
|
||
// phones scored the same rally, the two scoreboards drifted a point apart, and setupPoint then
|
||
// placed each P on its own deuce/ad side while each waited for the other to serve. The one rule
|
||
// is that the receiving phone judges; this was the one place still breaking it.
|
||
if (G.match && !G.net && !G.match.ending && !G.match.decided && G.phase === 'rally'
|
||
&& ball.active && ball.lastHit === 'P' && ball.vz < 0 && ball.z < COURT.netZ - 0.3
|
||
&& matchFallWillPlay()) {
|
||
const arz = zombies.find(z => z.variant === 'r');
|
||
const pred = arz && predictLanding();
|
||
if (pred && inCourt(pred.x, pred.z) && matchOutOfReachFlight(arz, pred)) {
|
||
G.match.decided = true; // same latch discipline as the bounce-1 hook below
|
||
G.matchEarly = true;
|
||
resolveInterlude('P');
|
||
G.matchEarly = false;
|
||
}
|
||
}
|
||
if (ball.y <= 0 && ball.vy < 0) {
|
||
// EXACT TOUCHDOWN, not the frame after it. The integrated position lands up to one full
|
||
// step PAST the true contact — at serve pace that is ~0.75 m of travel in 1/60 s, so a
|
||
// serve aimed half a metre inside the service line was routinely JUDGED long. Oliver saw it
|
||
// as "classic tennis gives double fault very often" (the match's line call only checks the
|
||
// whole court, which is why zombie tennis seemed immune). Rewind the step to y = 0 before
|
||
// the bounce position is read by anything — the line call, the bounce dust, the mark.
|
||
const yOld = ball.y - ball.vy * dt; // where this step began (vy already g-updated)
|
||
if (yOld > 0) {
|
||
const back = dt * (1 - yOld / (yOld - ball.y)); // time spent below the ground this step
|
||
ball.x -= ball.vx * back; ball.z -= ball.vz * back;
|
||
}
|
||
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 (ball.scored) { /* already called: this IS the natural halt it was left rolling for */ }
|
||
else 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.scored && 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';
|
||
// an uncaught toss is NOT a swing. The 'serve' pose used to survive into pretoss, and
|
||
// drawChar's pick (phase !== 'toss' → smash) flashed the SMASH frame at a ball the player
|
||
// never hit — Oliver: "the smash frame is always played even if I did not tap a second
|
||
// time". Back to idle, where the bounce stance picks him up.
|
||
const sv = score.server === 'P' ? P : O;
|
||
if (sv.pose === 'serve') { sv.pose = 'idle'; sv.poseT = 0; }
|
||
if (score.server === 'O') G.aiServeT = 0.8;
|
||
}
|
||
}
|
||
|
||
// ball boy: sprint across, scoop the ball in passing, despawn off the far side
|
||
const boy = G.boy;
|
||
if (boy) boy.mop ? mopRound(boy, dt) : fetchRound(boy, dt);
|
||
|
||
// The announced breather runs on its OWN clock, not on the 'over' beat it also sets: any beat that
|
||
// moves the phase underneath it (a duel taking the calm court, say) would otherwise strand the
|
||
// latch forever — the mgT lock, exactly. If the court is taken when it expires, the latch is
|
||
// re-armed and the set gets its horde later rather than losing it. Above the 'over' block so the
|
||
// wave lands before that block can put the player back in the serve stance for one frame.
|
||
if (G.hordeWarn > 0 && (G.hordeWarn -= dt) <= 0) {
|
||
G.hordeWarn = 0;
|
||
if (!startHorde()) G.pendingHorde = true;
|
||
}
|
||
// ...and the match's FALL runs on its own clock for exactly the same reason, above the 'over'
|
||
// block so it can hand the point back before that block puts the player in a serve stance.
|
||
tickMatchFall(dt);
|
||
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: warnHorde });
|
||
} else warnHorde(); // ...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();
|
||
}
|
||
}
|
||
}
|