The front door now continues the story. An act is its league cup, whose final is the boss's own body (acts II and IV play their band out of ladder order for that), then a ten-game gore chapter on the act's court. The chapter has no duel at all, takes the act's weapon stock into the rack, meets a mid-boss after game 5 (the checkpoint: restock and full blood) and the act boss after game 10, then the act card. Death is not permadeath: it counts the attempt, banks nothing on the boards, and TRY AGAIN restarts from the checkpoint the profile holds. Barrels hold what the act has earned. The chapter plays the arcade's games base+1..base+10 so every director curve keeps its slope. Profiles from before the story start it from the top (league, cups and champion reset; level kept). The server mirror stores the act and checkpoint and only ever moves them forward. Plain cards stand in for the screens (phase 3) and the transformation (phase 2). Six new tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2794 lines
191 KiB
JavaScript
2794 lines
191 KiB
JavaScript
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||
|
||
import { TUNE, UI, bossArt, STORY, 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, matchWound, STYLES, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } 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, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
||
import { haptic } from './haptics.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 { award, beginRunXp, prof, bonus, levelAt } from './profile.js';
|
||
import { recordEvent, recordFrame } from './recorder.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, placeReceiver, tickStory } 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.
|
||
// The recorded racket sample, pitched by the pace of the ball that just left it — 20 m/s of rally
|
||
// dink at 0.93, a 52 m/s serve at 1.09. Deliberately narrow: past about a tenth either way the
|
||
// sample stops sounding like the same racket. Read off the LIVE ball, so it is one call site's
|
||
// worth of code for every strike in the game.
|
||
export const strikeRate = () => 0.93 + Math.max(0, Math.min(0.16, (Math.hypot(ball.vx, ball.vz) - 20) / 200));
|
||
// ...and a fast ball KICKS THE CAMERA as it leaves. The third answer to the same report: the trail
|
||
// says how fast the ball is, the crack says how hard it was hit, and this says it in the one channel
|
||
// the striker cannot miss, because it moves the whole picture rather than something inside it.
|
||
// YOUR racket only (the far side's strike is something you watch — the same rule the haptic keeps),
|
||
// nothing under 26 m/s, and never over a shake that is already running: a bomb outranks a backhand.
|
||
const strikeKick = () => {
|
||
const p = Math.min(2.2, (Math.hypot(ball.vx, ball.vz) - 26) / 9);
|
||
if (p > 0 && !G.shake) G.shake = { t: 0.12, mag: p * 2.92 }; // mag*t/0.35 = the pixels it starts at
|
||
};
|
||
|
||
// How much of its horizontal pace a ball keeps through a bounce: the court's own number, times the
|
||
// shot's SPIN (ball.spin, 1 = none). Only the soft ball sets it — a drop carries backspin and dies.
|
||
// Every planner that rolls a bounce forward reads this, or it predicts a ball that is not there.
|
||
const bXZ = () => TUNE.bounceXZ * (ball.spin || 1);
|
||
// ...and backspin keeps it LOW as well: the vertical rebound gives up part of what the pace does
|
||
const bY = () => TUNE.bounceY * (1 - TUNE.spinLow * (1 - (ball.spin || 1)));
|
||
// THE DIP ENDS AT THE BOUNCE (2026-10-14, Oliver's pick). A shot's extra gravity (quality topspin,
|
||
// the serve's spin) is its DIVE IN FLIGHT — it was carried through the bounce, so a "heavy topspin"
|
||
// ball skidded away LOW afterwards and nearly every contact in his logs was 0-25 cm off the grass.
|
||
// Real topspin kicks UP. After the bounce a ball falls under plain gravity; the kick itself is
|
||
// bY/bXZ's spin factor. Tennis modes only — gore's balls keep theirs. Every planner rolling a bounce
|
||
// forward switches here too (intercept, bounceContact, ringCue, matchOutOfReachFlight) or it would
|
||
// predict the old low ball.
|
||
const gAfter = g => (rallyFeel() || G.mode === 'tennis' ? TUNE.gravity : g);
|
||
|
||
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.
|
||
// ...and in a rally it lands BELOW THE PLAYER, just behind his feet, and stays put (Oliver, 2026-10-14:
|
||
// "die Nachrichten wie shot of the day, textbook verdecken manchmal den Ball"). On the contact it rose
|
||
// straight into the path of the ball he had just struck; a serve's word sat on the far bounce, which
|
||
// is exactly where the return comes back through. The MOMENT is unchanged — only where it is drawn.
|
||
// Gore keeps its words on the bodies they belong to.
|
||
function praisePop(w, x, y, z) {
|
||
if (rallyFeel() || G.mode === 'tennis') popText(P.x, 0, P.z + TUNE.praiseBehind, w, undefined, true);
|
||
else popText(x, y, z, w);
|
||
}
|
||
|
||
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, col = null) {
|
||
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: col || DUST_COL[court.id] || '#98948a' });
|
||
}
|
||
// THE SKILL TRAIL (the leagues, phase 3): the player's OWN sprint dust wears the league colour —
|
||
// the aura made of motion, never paint (the painted-layer veto). League 1 keeps the shipped court
|
||
// dust: the tint itself is a progression reward. Read off the profile, not G.league, so the gore
|
||
// mode shows your colour too.
|
||
const skillDust = () => prof.lg > 1 ? (LEAGUES[prof.lg - 1] || LEAGUES[0]).color : null;
|
||
|
||
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;
|
||
}
|
||
|
||
// A FLICK'S LENGTH IS ITS STRENGTH. The rally aim read the gesture as three LANES — sideways past
|
||
// 0.38 of its own length took the whole corner, a hair under it took dead centre — so one flick came
|
||
// out "applied very strong" and the next "barely does anything", with nothing in between and no way
|
||
// to tell which you had asked for. Length scales the sway continuously now, exactly as it already
|
||
// did on the serve: a short swipe is a safe half-court ball, a committed one hunts the line. That is
|
||
// also what hands the OUT back to the player — the scatter can only sail a shot that was already
|
||
// aimed near the line, so going out becomes a risk you chose rather than one that was rolled for you.
|
||
// Keys have no length; they keep the full lane.
|
||
export const swayOf = aim => aim.mag === undefined ? aim.ax
|
||
: aim.fx * Math.min(1, Math.max(0, (aim.mag - TUNE.flickMin) / (TUNE.flickFull - TUNE.flickMin)));
|
||
|
||
// A return that left with a direction on it resets the flick lesson; a flat one is one more piece of
|
||
// evidence that the player does not know the gesture is there. Exported for the suite — the touch
|
||
// flick cannot be pressed headlessly, and this rule is the whole of the lesson's trigger.
|
||
// It reads the same sway the shot does, so a swipe too short to bend the ball is honestly counted
|
||
// as flat rather than resetting the lesson it just failed to use.
|
||
export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1);
|
||
|
||
// THE LEAGUES' pace tier, PER TERRITORY (2026-08-29, THE CLIMB): the tempo hangs on the band of
|
||
// the CURRENT OPPONENT, not on the player's league — the low ladder plays at its own shipped
|
||
// tempo and the world audibly pulls away as a climb crosses each band border. In the cup the two
|
||
// readings coincide (the pool IS your band); the training court now practises a body at the
|
||
// tempo you would really meet it at. Multiplies ball pace on BOTH rackets (doHit, both serve
|
||
// paths) and the opponent's legs (rzChaseSpeed), so the world speeds up as one piece and the
|
||
// reach tests keep believing the legs they measure. Netplay is a human and exempt.
|
||
export const leaguePace = () => G.match && !G.net
|
||
? 1 + (leagueOfRung(G.match.opp).id - 1) * TUNE.leaguePace : 1;
|
||
|
||
// THE DUEL LEARNS (the leagues, phase 4): 0 fresh, 1 at TUNE.duelRampGames — gore's own clock,
|
||
// so game 0 is exactly the shipped duel and the league never rekeys the director. Exported for
|
||
// the suite: the ramp is what makes a late tin worth something again.
|
||
export const duelSkill = () => G.match ? 0 : Math.min(1, G.runGames / TUNE.duelRampGames);
|
||
|
||
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
|
||
// ...and a landed swing WITH A DIRECTION ON IT ends the flick lesson. Counted here rather than
|
||
// at the gesture because a flick that arrives too late to be read is a flick that taught the
|
||
// player nothing: what matters is whether the ball left with an aim on it. Held keys count too.
|
||
G.flatStreak = flatCount(G.flatStreak, aim);
|
||
}
|
||
// 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, sway = 0, soft = null;
|
||
let s = (TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed) * leaguePace();
|
||
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;
|
||
sway = swayOf(aim); // -1..1, continuous on touch — see swayOf
|
||
// AN AIMED SHOT KEEPS ITS DIRECTION. The scatter used to be symmetric, and at rushed quality
|
||
// it was BIGGER than the quality-shrunk aim — a mistimed left flick landed anywhere in a 4 m
|
||
// blob around "slightly left", i.e. usually centre and sometimes right. It read as the ball
|
||
// landing wherever the opponent stood (2026-08-20, "trying to move the approaching ball with
|
||
// a flick to the left is almost impossible"). An OUTWARD roll is untouched — the committed
|
||
// swipe's chance of sailing wide is a chosen risk with a tuned shape (flickReach) and this
|
||
// must not soften it — but an inward roll now keeps only TUNE.aimTrust of itself, so bad
|
||
// timing costs WIDTH, never the direction that was asked for. Player only: the opponent's
|
||
// symmetric scatter is its width mechanic (matchWide clamps it, not this).
|
||
let e = err();
|
||
if (ch.side === 'P' && sway && e * Math.sign(sway) < 0) e *= TUNE.aimTrust;
|
||
tx = sway * (toFar ? TUNE.flickWidth : 2.7) * reach + e;
|
||
// ...AND AN AIMED BALL NEVER LANDS ON THE WRONG SIDE OF YOU. The lane is ABSOLUTE (a fully
|
||
// relative lane was tried 2026-08-20 and feel-vetoed the same day — "way too difficult now,
|
||
// I am dying constantly of out"), and an absolute lane has a blind spot: a mistimed left
|
||
// flick's target sits only slightly left of CENTRE, which is RIGHT of a player standing in
|
||
// the left service box — so the ball visibly flew right out of a left flick ("the ball
|
||
// sometimes actually flies a little to the right, although I flicked left"). The target is
|
||
// clamped to the player's own x on the flicked side now (bounded at flickWidth, so a body
|
||
// parked in the alley cannot conjure an out): from the left box a left flick goes at least
|
||
// down your own line, from centre it can no longer cross centre, and a perfect corner flick
|
||
// is untouched. No new out risk — the clamp only ever pulls the target TOWARD the line the
|
||
// player is standing on, never past flickWidth, and the outward scatter tail is unchanged.
|
||
if (ch.side === 'P' && sway) {
|
||
const bound = Math.max(-TUNE.flickWidth, Math.min(TUNE.flickWidth, ch.x));
|
||
tx = sway < 0 ? Math.min(tx, bound) : Math.max(tx, bound);
|
||
}
|
||
// a PUT-AWAY goes for the line, not the rally lane (the match's width dial below still bounds it,
|
||
// so the bottom of the ladder cannot paint lines even when it punishes)
|
||
if (aim && (aim.kill || aim.pass)) tx = (aim.ax || (P.x > 0 ? -1 : 1)) * TUNE.punishX + err() * 0.3;
|
||
// ...and the player's SMASH goes for the open court the same way: his side if he flicked one, else
|
||
// away from the body across the net. Down the middle, deep, it came straight back to the baseliner
|
||
if (ch.side === 'P' && aim && aim.smash) {
|
||
const foe = G.match ? zombies.find(z => z.variant === 'r') : O;
|
||
tx = (sway ? Math.sign(sway) : foe && foe.x > 0 ? -1 : 1) * TUNE.punishX + err() * 0.3;
|
||
}
|
||
// THE COURT HAS ITS LENGTH: in a rally mode the ball goes DEEP, and bad timing costs DEPTH — a
|
||
// mistimed ball sits up short, which is the ball a real opponent attacks — never length, so
|
||
// depth adds no out. The down-flick keeps its old short ball (it becomes the soft ball next).
|
||
// THE SOFT BALL (step 3, Oliver's pick 2026-10-13): in a rally mode the DOWN-flick is soft, always,
|
||
// and its LENGTH is how far — a short one is a drop just over the net, a long one a lob into the
|
||
// back corner (the opponent's moonball uses the space between). One gesture, one meaning, whoever is
|
||
// where: this is NOT the context-armed lob that was cut on 2026-08-22. Mistiming pulls it toward
|
||
// softSitter — a drop that sits up, a lob that falls short: the easy ball either way.
|
||
// `aim.soft` lets a caller with no swipe (the opponent, step 2) ask for one; keys take the lob.
|
||
// TWO shots, not a continuum (Oliver on v363: "nach unten erzeugt gefühlt immer einen Lob ... mir
|
||
// ist ein Stoppball nicht gelungen"). The swing fires at flickMin and the length is read at
|
||
// contact, by when a thumb has usually travelled on — and on a continuum even 50 px already
|
||
// floated the ball to mid-court. Below dropMax it is the drop, at or past it the lob.
|
||
soft = rallyFeel() && aim.az > 0
|
||
? (aim.soft ?? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? 1 : 0))
|
||
: null;
|
||
if (soft !== null) {
|
||
const want = COURT.netZ - TUNE.dropNet + (TUNE.lobDeep - COURT.netZ + TUNE.dropNet) * soft; // from his baseline
|
||
const d = want + (TUNE.softSitter - want) * (1 - quality) * TUNE.softErr;
|
||
tz = toFar ? d : COURT.L - d;
|
||
} else {
|
||
const deep = rallyFeel() && aim.az <= 0;
|
||
const tzBase = aim.az < 0 ? (deep ? TUNE.rallyDeep : SHOT_DEPTH.deep)
|
||
: aim.az > 0 ? SHOT_DEPTH.short : deep ? TUNE.rallyMid : SHOT_DEPTH.mid;
|
||
const dz = deep ? (1 - quality) * TUNE.shortK * (0.5 + Math.random()) : err() * 0.8;
|
||
tz = deep ? (toFar ? tzBase + dz : COURT.L - tzBase - dz) : (toFar ? tzBase : COURT.L - tzBase) + dz;
|
||
}
|
||
// 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;
|
||
// ...and an OVERHEAD taken out of the air is the real smash (trySwing marks it): hit it hard
|
||
if (ch.side === 'P' && aim.smash) s *= 1 + TUNE.smashPace * quality;
|
||
aimedLane = true;
|
||
}
|
||
tx = Math.max(-5, Math.min(5, tx));
|
||
// THE READ (see config's dial block): the opponent studies where your rally shots GO. Three
|
||
// lanes, bucketed off the aimed target — run readHabit of the same lane together and the habit
|
||
// is learned: the NEXT same-lane ball is `expected` (anticipated in racketAI), a lane break is
|
||
// `surprised` (wrong-footed, for this one flight). Solo match rally shots only: netplay's
|
||
// opponent is a human who does his own reading, and serves keep their own game.
|
||
if (G.match && !G.net && ch.side === 'P' && aimedLane) {
|
||
const d = G.match, lane = tx < -1.1 ? -1 : tx > 1.1 ? 1 : 0;
|
||
d.surprised = (d.laneRun || 0) >= TUNE.readHabit && d.lastLane !== undefined && lane !== d.lastLane;
|
||
d.expected = (d.laneRun || 0) >= TUNE.readHabit && lane === d.lastLane;
|
||
d.laneRun = lane === d.lastLane ? (d.laneRun || 0) + 1 : 1;
|
||
d.lastLane = lane;
|
||
// the mechanic announces itself the moment it fires — on the rungs young enough to barely
|
||
// lean, the flash is the whole lesson for later
|
||
if (d.surprised && (d.opp - 1) / (LADDER - 1) > 0.2) G.flash = { text: 'WRONG-FOOTED.', t: 1.0 };
|
||
}
|
||
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 + matchStep() * 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 + matchStep() * 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 && (sway || 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. The soft ball carries none.
|
||
const g = soft !== null ? TUNE.gravity : TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
||
// ...and its PACE IS DERIVED FROM ITS HEIGHT (the cut lob's ballistics, 4f4534e): to peak at
|
||
// `apex` it must leave at vy = sqrt(2g(apex - y)), which fixes the hang time, and the ground still
|
||
// to cover fixes the speed — the same arc from the baseline and from the net. After the
|
||
// opponent's pace dials on purpose: a floater is a floater from any racket.
|
||
if (soft !== null) {
|
||
const apex = TUNE.dropApex + (TUNE.lobApex - TUNE.dropApex) * soft;
|
||
const vy0 = Math.sqrt(2 * TUNE.gravity * Math.max(0.5, apex - ball.y));
|
||
const hang = (vy0 + Math.sqrt(vy0 * vy0 + 2 * TUNE.gravity * Math.max(0, ball.y - 0.08))) / TUNE.gravity;
|
||
s = Math.hypot(tx - ball.x, tz - ball.z) / hang;
|
||
}
|
||
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;
|
||
// a drop carries backspin and DIES after its bounce; it fades out across the soft range to a lob
|
||
ball.spin = soft === null ? (aim && aim.spin) || 1 : 1 - (1 - TUNE.dropSpin) * Math.max(0, 1 - soft * 2);
|
||
ball.soft = soft !== null; // ...and it is DISGUISED: racketAI reads it late (TUNE.softRead)
|
||
// ...and a short ball taken is an APPROACH: he follows it in (rallyHome reads this while it flies).
|
||
// Never against a man already at the net — that ball is a PASS, and two volleyers is the old
|
||
// reflex duel at a quarter of a second per shot. ball.from is the striker's distance from the net.
|
||
ball.approach = rallyFeel() && ball.rx && ball.rx.short && !(ball.from < TUNE.netFrom) ? ch.side : null;
|
||
ball.retreat = null; // his own way back is a DOUBLE TAP while this ball flies (moveCall), never the shot
|
||
ball.smash = !!(aim && aim.smash);
|
||
ball.from = Math.abs(ch.z - COURT.netZ);
|
||
ball.lastHit = ch.side; ball.bounces = 0; ball.rx = null; // a new shot: the receiver plans it afresh
|
||
ball.heels = aim && aim.kill ? TUNE.punishRead : 0; // a put-away: he is on his heels (see the auto-run)
|
||
// ...and a player AT THE NET (or rushing it) split-steps before he reads the pass, exactly as the CPU's
|
||
// net man does (netReact): with no read at all his 2.5 m volley reach made him unpassable
|
||
if (ch.side !== 'P' && rallyFeel() && (Math.abs(P.z - COURT.netZ) < TUNE.netFrom || P.rushing)) ball.heels = Math.max(ball.heels, TUNE.netReact);
|
||
// THE SWING LOG's other half: what came at you. A LOST "passed you" line is unreadable without it —
|
||
// was that ball reachable at all? And "never swung" is judged per incoming ball, not once a point.
|
||
if (ch.side !== 'P' && swingLog()) {
|
||
G.swung = false; G.rallyNear = null;
|
||
const land = predictLanding(), mark = !land ? '' : (() => {
|
||
const dx = land.x - P.x, inside = r2(COURT.L - land.z);
|
||
const out = Math.abs(land.x) > COURT.W / 2 || land.z > COURT.L || land.z < COURT.netZ;
|
||
return ` · lands ${r2(Math.abs(dx))}m ${dx < 0 ? 'left' : 'right'} of you, ${inside}m inside your line${out ? ' (OUT)' : ''}`;
|
||
})();
|
||
const kind = aim && aim.kill ? 'PUT-AWAY of your weak ball' : soft === null ? (aim && aim.az < 0 ? 'deep' : 'drive') + (ball.spin > 1 ? ' topspin' : ball.spin < 1 ? ' slice' : '')
|
||
: soft === 0 ? 'drop' : soft >= 1 ? 'lob' : 'moonball';
|
||
logSwing({ result: 'THEM', reason: `${kind} q=${r2(quality)} · ${Math.round(Math.hypot(ball.vx, ball.vz))}m/s${mark}`
|
||
+ (ball.approach === ch.side ? ' · coming in' : ''), px: r2(P.x), pz: r2(P.z) }); // where HE stood when it left
|
||
}
|
||
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
|
||
recordEvent('shot', { kind: 'return', side: ch.side, quality, aim: aim ? { ...aim } : null,
|
||
target: [tx, tz] });
|
||
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
|
||
// ...and the CRACK carries the pace (same report as the ball streak): one recorded sample, pitched
|
||
// by how fast the ball actually left. It is the striker's cue at the one moment he is looking at
|
||
// his own racket rather than at the far court.
|
||
sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true, rate: strikeRate() }); // recorded racket strike, per side
|
||
if (ch.side === 'P') strikeKick();
|
||
// CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not
|
||
// something your hand did, and buzzing for both halves of every rally is the numbing failure mode.
|
||
if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one
|
||
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) praisePop(w, ball.x, ball.y, ball.z);
|
||
}
|
||
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) * leaguePace();
|
||
// 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.
|
||
// exported for the headless suite only: it is the one honest way to serve without a thumb, and a
|
||
// serve-defence test that fakes its own ball is testing a ball the game never hits (it did, once —
|
||
// the fake was a flat rocket that skidded out the back and nothing could have returned it).
|
||
export 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) * leaguePace();
|
||
// 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.rx = null; ball.heels = 0; ball.retreat = null; ball.smash = false; ball.spin = 1; ball.soft = false;
|
||
// a serve-and-volleyer follows its serve in (STYLES.sv) — the serve is its approach shot
|
||
ball.approach = sv.side === 'O' && styleOf(zombies.find(z => z.variant === 'r'))?.sv ? 'O' : null; ball.from = Math.abs(sv.z - COURT.netZ);
|
||
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, rate: strikeRate() }); // recorded serve strike, per side
|
||
recordEvent('shot', { kind: 'serve', side: sv.side, quality, perfect, aim: aim ? { ...aim } : null });
|
||
if (sv.side === 'P') strikeKick();
|
||
if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit
|
||
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)
|
||
// HOW BIG THIS ONE WAS. vol is already the size of the moment everywhere it is called from (a
|
||
// won rally 0.25, a game 0.6, a match or an EPIC 1.0), so the stand reads it rather than growing
|
||
// a second scale of its own: past 0.6 the wave rolls, and the photographers in the stand fire a
|
||
// burst proportional to it. Held at the LOUDEST while an applause is live — a small cheer landing
|
||
// inside a big one must not quietly demote it.
|
||
crowd.pop = Math.max(crowd.cheer > 0 ? crowd.pop : 0, Math.min(1, vol));
|
||
crowd.burst += Math.round(crowd.pop * 7);
|
||
// 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]++;
|
||
// THE ONE XP HOOK for points and games: every mode's point converges here (pointTo, matchPoint,
|
||
// splatPoint), so the leagues' clock cannot miss a mode or double-count one. award() itself
|
||
// mutes the no-stakes modes (training, drill, bench, test).
|
||
if (side === 'P') award('point');
|
||
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) {
|
||
score.games[i]++; score.pts = [0, 0];
|
||
if (side === 'P') award('game');
|
||
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)
|
||
G.passStreak++; // their ball died on your side untouched: two in a row and the ghost hand shows the tap (render.drawHitHint)
|
||
logPoint(side, reason);
|
||
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.
|
||
// THE STORY's chapter has no duel, and its two bosses are its own: the mid-boss after game 5
|
||
// (the checkpoint) and the act's boss after game 10. The arcade schedule below never runs there.
|
||
const chap = G.story && G.story.part === 'chap';
|
||
if (chap) {
|
||
const rel = G.runGames - G.story.base;
|
||
if (rel >= STORY.length) G.storyBoss = 'end';
|
||
else if (rel === STORY.midAt && !G.story.midDone) G.storyBoss = 'mid';
|
||
} else 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 (!chap && 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
|
||
haptic('select'); // ...and it is felt, which is what a physical rack would do
|
||
}
|
||
|
||
// 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
|
||
// THE WEAPON LICENCE (the leagues): the league licenses a weapon, the run's `from` gate paces
|
||
// it — BOTH must hold (config.TIN_LEAGUE; barrels apply the same licence in pickBarrelWeapon).
|
||
// Pure and exported so the suite can pin the pot.
|
||
export const tinLadder = () => [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w =>
|
||
G.runGames >= BARREL_WEAPONS[w].from && prof.lg >= (TIN_LEAGUE[w] || 0))];
|
||
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 = tinLadder();
|
||
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)
|
||
// league 5 duels pay DOUBLE tins — the top of the club tips well (config.TIN_DOUBLE_LEAGUE)
|
||
const tinN = WEAPONS[id].tin * (prof.lg >= TIN_DOUBLE_LEAGUE ? 2 : 1);
|
||
grantWeapon(id, tinN);
|
||
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: '×' + tinN,
|
||
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(keepNet = false) {
|
||
recordEvent('run_end', { outcome: G.done?.won ? 'won' : G.mode === 'eaten' ? 'lost' : 'abandoned' });
|
||
// netplay: resetState wipes G.net below; the socket must not outlive the run it carried — unless
|
||
// the REMATCH is what asked for the reset, in which case the same pairing carries the next one
|
||
if (!keepNet) netClose();
|
||
resetZombies(); resetInput(); resetState(); setupPoint();
|
||
beginRun(); // ...and the stats bag, which is the only state that does not live on G
|
||
beginRunXp(); // ...and the profile's run delta, same rule (the xp itself is already banked)
|
||
}
|
||
|
||
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
|
||
haptic('heavy'); // ...and the hand feels the world stop
|
||
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
|
||
recordEvent('fault', { serveNumber: G.serveNum });
|
||
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
|
||
// "Fault. Second service." — the second half is the whole point of the call: a lone "fault"
|
||
// never said that the player still had a serve coming (Oliver, 2026-08-22). voiceSeq, not two
|
||
// voice() calls: the umpire is one man with one microphone and the second line would be DROPPED.
|
||
G.serveNum = 2; voiceSeq(['fault', 'second_service']); G.phase = 'over'; G.overT = 1.4; sendBoy();
|
||
// ...and in a MATCH the body goes back on its receiving mark for the second serve, exactly as
|
||
// matchReady puts it there for a first one — it is standing wherever it chased the fault to.
|
||
// Not in netplay: the called ball is left rolling there (net.js), and matchReady's ball.active
|
||
// = false would freeze it mid-roll on the far phone.
|
||
if (G.match && !G.net) placeReceiver();
|
||
}
|
||
// A DOUBLE FAULT IS A POINT, AND A POINT IS SCORED BY THE MODE THAT OWNS THE COURT. pointTo is
|
||
// TENNIS scoring; the duel and the match settle points through resolveInterlude (blood, the fall,
|
||
// the ladder) and a raw pointTo there would bank a number and skip all of it — the same seam the
|
||
// interlude branch of onBounce is written around. Nothing reached this line from an interlude
|
||
// before, because those modes had no fault in them at all.
|
||
else if (G.interlude) { voice('double_fault'); resolveInterlude(other(score.server), 'double_fault'); }
|
||
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
|
||
}
|
||
|
||
// How fast this body chases a ball. ONE definition, because racketAI's legs and the two
|
||
// out-of-reach tests that end a rally early must never disagree about what it can get to.
|
||
// THE RETURN DASH is the returner's first step: a serve is the one ball in the game that is
|
||
// hit before he is moving at all, and against a hard one into the corner the walk-up simply
|
||
// could not start in time (Oliver: the first ten opponents can be killed the moment you serve).
|
||
// It lasts exactly as long as the serve does — G.wasServe is cleared at the first bounce and at
|
||
// any return — so a rally is chased at the ordinary ladder speed and nothing else moves.
|
||
// How much this body has LEARNED — THE READ's rung scale, 0 at the shambler, 1 at the champion.
|
||
// One definition, because the read boost, the wrong-foot slow and the surprise whiff must all
|
||
// answer "how smart is this one" identically or the twist pulls in three directions at once.
|
||
const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0;
|
||
// The Climb is a repeatable arcade run: less nursery at the bottom. Four extra progression
|
||
// steps taper to zero at opponent eight; identities, territory tempo and cup onboarding stay put.
|
||
// Share this across movement, intercept planning and returns so all four dials agree.
|
||
const matchStep = () => {
|
||
const n = G.match.opp - 1;
|
||
return n + (!G.net && !G.match.cup && !G.match.train ? 4 * Math.max(0, 1 - n / 7) : 0);
|
||
};
|
||
// THE LEGS HAVE A KNEE (2026-10-14). The ladder multiplied speed rung on rung with nothing on top:
|
||
// 7.3 m/s at the RUNNER, 10.6 at the HEAD COACH, 15.7 at the CHAMPION — a sprinter's top speed and
|
||
// 2.5x the player's ~6.2. Under step 1's geometry a late opponent at the net then covered every pass.
|
||
// Up to legKnee nothing changes; past it only legSoft of the growth survives, so the order of the
|
||
// ladder (and every like-for-like speed test) holds while the champion lands near ~7.5. Wrong-footing,
|
||
// the read, placement and consistency are what climb past it now.
|
||
const knee = v => v <= TUNE.legKnee ? v : TUNE.legKnee + (v - TUNE.legKnee) * TUNE.legSoft;
|
||
const rzChaseSpeed = rz => knee(rzRawSpeed(rz));
|
||
const rzRawSpeed = rz => TUNE.racketSpeed * leaguePace()
|
||
* (G.match ? (TUNE.matchSpeedBase + matchStep() * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1)
|
||
: 1 + TUNE.duelSpeedMax * duelSkill())
|
||
* (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1)
|
||
// wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE
|
||
// so the intercept planner and the reach tests believe it too (a surprised champion plans like
|
||
// a body that genuinely cannot get there, which is the whole point)
|
||
* (G.match && G.match.surprised ? 1 - TUNE.surpSlow * learnK(rz) : 1)
|
||
// ...and a net man who has been LOBBED turns and runs backwards: real legs do that slowly, and it is
|
||
// what makes the lob the answer to the net (the planner believes it too)
|
||
* (ball.lastHit === 'P' && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1); // HIS plan, not the player's
|
||
|
||
// 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 * bY()) / gAfter(g));
|
||
const vx2 = ball.vx * bXZ(), vz2 = ball.vz * bXZ();
|
||
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) { praisePop(ball.praise, ball.x, 0.3, ball.z); 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;
|
||
// A MISSED SERVE BUYS A SECOND ONE, IT DOES NOT LOSE THE POINT (Oliver, 2026-08-22: "when
|
||
// serving results in an out there should be a second service"). These modes had no service
|
||
// rule at all: the first serve was judged as an ordinary ground stroke, so a serve into the
|
||
// net or over the baseline handed the point away on the spot — in the match, that is a point
|
||
// and blood for a first serve. It goes through the same fault() the tennis modes use, so the
|
||
// second serve, the umpire's call and the double fault all come for free.
|
||
// The BOX is deliberately still not enforced here (see the note in hitServe): a deep-but-in
|
||
// serve stays legal in the match, which is the concession that mode was tuned around.
|
||
if (!inCourt(ball.x, ball.z) || !okSide) {
|
||
endFlight();
|
||
if (G.wasServe) fault(); else { resolveInterlude(other(ball.lastHit), 'out'); 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', 'unreachable_after_bounce');
|
||
G.matchEarly = false;
|
||
}
|
||
} else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit, 'double_bounce'); }
|
||
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);
|
||
// THE LEVEL IS YOU, in the league (2026-08-29): the player's legs and contact window grow subtly
|
||
// with the level — LEAGUE modes only (the climb, the cup, the training court). Classic keeps its
|
||
// own difficulty picker, netplay is a fair human match, gore is untouched (its trio lives in
|
||
// profile.bonus too, read elsewhere). Level 1 is factor 1: the shipped baseline is the floor.
|
||
// THE PEER GIFT turns the walk-on's displayed level into a real promise. It exists only in the
|
||
// canonical cup and its exact training rep: arcade remains a stable whole-club challenge. Fifteen
|
||
// levels down is today's wall, parity gets the full gift, and being underlevelled is never a malus.
|
||
export const peerK = () => G.match && !G.net && (G.match.cup || G.match.train)
|
||
? Math.max(0, Math.min(1, 1 - (foeLevel(G.match.opp) - levelAt(prof.xp)) / TUNE.peerGap)) : 0;
|
||
export const legLevel = () => G.match && !G.net ? bonus().run * (1 + TUNE.peerRun * peerK()) : 1;
|
||
export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.peerWindow * peerK()) : 1;
|
||
// legLevel rides INSIDE assistK, so the mover's legs and intercept's reachability can never
|
||
// disagree about what those legs can get to (the rzChaseSpeed rule, player-side).
|
||
export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist)
|
||
* legLevel();
|
||
|
||
// THE COURT HAS ITS LENGTH. How the receiver plays THIS shot, decided once as it leaves the racket
|
||
// (every strike clears ball.rx): at the net he volleys; from the back he lets it bounce, and a SHORT
|
||
// landing pulls him in to take it on the rise. Latched, so a man running in for a short ball does not
|
||
// turn volleyer the moment he crosses netFrom. Rally modes only — the callers gate it.
|
||
function rxPlan(ch) {
|
||
if (!ball.rx) {
|
||
const land = ball.bounces === 0 ? predictLanding() : null;
|
||
const depth = land ? (ch.side === 'P' ? COURT.L - land.z : land.z) : 0;
|
||
const st = styleOf(ch);
|
||
// a body still RUSHING the net (serve and volley, a charge behind its approach) plays it as a
|
||
// net man from wherever its run has got to — a serve return never counts
|
||
ball.rx = { net: Math.abs(ch.z - COURT.netZ) < TUNE.netFrom || !!(ch.rushing && !G.wasServe),
|
||
short: !G.wasServe && depth > TUNE.shortBall - (st && st.charge || 0) };
|
||
}
|
||
return ball.rx;
|
||
}
|
||
// THE STYLES (step 2): how this body plays — the solo match's opponents only (see config.STYLES)
|
||
const styleOf = ch => (G.match && !G.net && ch && ch.foe && STYLES[ch.foe.style]) || null;
|
||
// ...a baseliner lets a low ball bounce: only a man at the net takes it out of the air below the tape
|
||
const letBounce = () => ball.bounces === 0 && ball.y < COURT.netH && ball.rx && !ball.rx.net;
|
||
// ...and where he recovers to is where he IS: at the net he holds the net, otherwise he goes back
|
||
// behind his baseline. Read live — recovery always moves him away from netFrom, so it cannot dither.
|
||
// The one exception is the APPROACH: having taken a short ball, he follows it in.
|
||
// The PLAYER's double tap overrides all of that with a STANCE (P.post) that lasts until he changes it.
|
||
const atNetHome = ch => ch.side === 'P' && P.post ? P.post === 'net'
|
||
: (Math.abs(ch.z - COURT.netZ) < TUNE.netFrom && ball.retreat !== ch.side) || ball.approach === ch.side;
|
||
const rallyHome = ch => ({ x: 0, z: atNetHome(ch)
|
||
? COURT.netZ + (ch.side === 'P' ? TUNE.netStand : -TUNE.netStand)
|
||
: ch.side === 'P' ? COURT.L + TUNE.rallyBack : -TUNE.rallyBack });
|
||
// Where a baseliner meets this ball: after the bounce, falling through rallyHit, or at the top of a
|
||
// bounce that never gets that high. A SHORT ball is met on the RISE instead, moving in. Closed form
|
||
// off predictLanding, for racketAI (intercept walks the flight for itself).
|
||
function bounceContact(short) {
|
||
let g = ball.g || TUNE.gravity;
|
||
let { x, y, z, vx, vy, vz } = ball;
|
||
if (ball.bounces === 0) {
|
||
const p = predictLanding(); if (!p) return null;
|
||
x = p.x; z = p.z; y = 0;
|
||
vy = -(vy - g * p.t) * bY(); vx *= bXZ(); vz *= bXZ();
|
||
g = gAfter(g); // the dip ends at the bounce
|
||
}
|
||
const apex = y + Math.max(0, vy) ** 2 / (2 * g), h = Math.min(TUNE.rallyHit, apex);
|
||
const r = Math.sqrt(Math.max(0, vy * vy - 2 * g * (h - y)));
|
||
const at = t => ({ x: x + vx * t, z: z + vz * t });
|
||
const fall = at(Math.max(0, (vy + r) / g)), rise = at(Math.max(0, (vy - r) / g));
|
||
// a ball that will only come down past rallyBehind (a deep lob's high bounce) is taken on the RISE
|
||
// instead — waiting for it would mean standing in the stand, and above CONTACT.maxY it is unplayable
|
||
return short && vy > 0 || fall.z < -TUNE.rallyBehind ? rise : fall;
|
||
}
|
||
|
||
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;
|
||
let g = ball.g || TUNE.gravity; const dt = 0.01; // g switches to plain gravity at the simulated bounce (gAfter)
|
||
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
|
||
const home = rallyFeel() ? rallyHome(ch) : HOME[side];
|
||
const plan = rallyFeel() ? rxPlan(ch) : null;
|
||
// a ball that will clear a net man's racket is a LOB — he turns and runs it down (racketAI's rule)
|
||
if (plan && plan.net && (ball.z - ch.z) * vz < 0) {
|
||
const tc = (ch.z - z) / vz;
|
||
if (tc > 0 && y + vy * tc - 0.5 * g * tc * tc > (side === 'P' && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY)) { plan.net = false; plan.lob = true; }
|
||
}
|
||
const back = plan && !plan.net; // a baseliner lets it bounce
|
||
// ...and only where his legs may go: the player is held on screen (xLimit), and since the dip ends at
|
||
// the bounce a kicking ball drifts on sideways long enough to plan him out to x 6.5-8.7 — he ran to
|
||
// the edge of the frame and watched it pass (his v369 log, 65.77 s and 84.26 s)
|
||
const xl = side === 'P' ? xLimit() : 99;
|
||
// ...and the PLAYER's reach is judged from where his chase BEGAN plus the time he has run since (P.chase),
|
||
// not from where he stands this frame. Judged live, his own step moved the answer: a deep point became
|
||
// reachable one frame and not the next, and the plan flipped 2.2 m every other frame (the shake).
|
||
const o = side === 'P' && P.chase ? P.chase : { x: ch.x, z: ch.z, t: 0 };
|
||
// ...with HYSTERESIS: the flight is sampled every 10 ms against a 16.7 ms frame, so a candidate on a
|
||
// margin passed one frame and failed the next as the grid slid — a low slice whose bounce peaks at ~0.46 m
|
||
// against the 0.45 m floor below, measured flipping the plan 2.2 m every other frame. The point he is
|
||
// already running to keeps runKeep of time and 6 cm of height slack.
|
||
const near = (px, pz) => side === 'P' && P.goal && Math.hypot(px - P.goal.x, pz - P.goal.z) < 0.6;
|
||
const can = (px, pz, i, k = 1) => Math.hypot(px - o.x, pz - o.z) / (spd * k) <= o.t + i * dt + (near(px, pz) ? TUNE.runKeep : 0);
|
||
let fallback = null, deepest = null, anyHit = null, rise = null, nb = ball.bounces;
|
||
// ...but the PLAYER lobbed at the net first looks for the overhead: the first point the lob comes down
|
||
// into his smash band (smashMaxY) that his backpedal reaches in time. A short lob is smashed; one he
|
||
// cannot get under is run down after its bounce, as before, and his reply is capped.
|
||
// DECIDED ONCE, when the lob is read (plan.over, latched like the plan itself): re-asked every frame, his
|
||
// backpedal toward the run-down brought the overhead into reach on the way, and every lob was smashed
|
||
if (side === 'P' && plan && plan.over) return plan.over;
|
||
const smashing = side === 'P' && plan && plan.lob && plan.over === undefined;
|
||
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 * bY(); vx *= bXZ(); vz *= bXZ(); nb++; g = gAfter(g); }
|
||
if (smashing && nb === 0 && vy < 0 && y <= TUNE.smashMaxY - 0.2 && y >= TUNE.smashMinY && Math.abs(x) <= xl
|
||
&& can(x, z, i, TUNE.smashRun)) return (plan.over = { x, z, ti: o.t + i * dt });
|
||
// FROM THE BACK: meet it between its bounce and the next one — at the top or falling through
|
||
// rallyHit+0.3, from as far as rallyBehind behind the line; a short ball the first moment it is
|
||
// up off the grass. Only if nothing like that is reachable do the old candidates below apply.
|
||
// (up to rallyTop since the dip ends at the bounce: a kicking ball is taken at the top of its
|
||
// bounce, chest high, rather than chased forward and volleyed — 40% volleys was the first cut)
|
||
if (back && nb === 1 && i > 5 && y > (near(x, z) ? 0.39 : 0.45) && y < (plan.short ? TUNE.rallyHit + 0.6 : TUNE.rallyTop)) {
|
||
const inB = (side === 'P' ? z <= COURT.L + TUNE.rallyBehind + (near(x, z) ? 0.1 : 0) : z >= -TUNE.rallyBehind) && Math.abs(x) <= xl;
|
||
if (inB && can(x, z, i)) {
|
||
if (plan.short || vy < 1) return { x, z, ti: o.t + i * dt };
|
||
rise ||= { x, z, ti: o.t + i * dt };
|
||
}
|
||
}
|
||
// 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), ti: o.t + i * dt };
|
||
if (Math.abs(x) <= xl && can(pt.x, pt.z, i)) 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), ti: o.t + i * dt };
|
||
const reachable = Math.abs(x) <= xl && can(pt.x, pt.z, i);
|
||
if (y > 0.7 && reachable && !back) 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
|
||
}
|
||
}
|
||
if (smashing) { plan.over = null; if (P.post === 'net') P.post = 'base'; } // no overhead in reach: he runs it down, and stays back
|
||
// ...and a frame that finds NOTHING mid-chase holds the plan it had: a dying drop's bounce sits on the
|
||
// 0.35 m floor, and every other frame without a candidate sent him home, 10 m away, and back again
|
||
const hold = side === 'P' && P.chase && P.goal ? P.goal : null;
|
||
const to = rise || fallback || deepest || anyHit || hold || home; // zone points first: meeting deep is still better
|
||
// a ball no point of which he can reach still gets a run — to the nearest spot he is allowed to stand
|
||
return Math.abs(to.x) > xl ? { x: Math.sign(to.x) * xl, z: to.z, ti: to.ti } : to;
|
||
}
|
||
|
||
// THE GOLD RING, TIMED (2026-10-14). The approach ring used to go gold at a DISTANCE (matchRingSweet
|
||
// 2.7 m) — right for the ~20 m/s rally balls it was calibrated on, and too early for every slow ball
|
||
// steps 1-3 brought in: a pusher's moonball, a lob, a drop, a deep ball braked by its bounce. A
|
||
// gold-ring tap on a slow ball missed "30 ms after the window" (the swing log's first catch). Now the
|
||
// cue rolls the ball forward to the moment it enters the player's real contact reach (the same reach,
|
||
// height band and let-it-bounce rule trySwing applies) and is gold while a finger landing NOW centres
|
||
// that moment in the swing window — whatever the pace. TUNE.ringTime 0 restores the distance ring.
|
||
export function ringCue() {
|
||
if (!ball.active || ball.lastHit !== 'O' || ball.vz <= 0) return null;
|
||
let g = ball.g || TUNE.gravity;
|
||
const dt = 1 / 120;
|
||
let { x, y, z, vx, vy, vz } = ball, b = ball.bounces;
|
||
// ...against where the player WILL be: the auto-run is still carrying him to its target at the pace
|
||
// he is running now, and measuring against where he stands made the cue jump 50-180 ms a frame
|
||
const sp = Math.hypot(P.vx || 0, P.vz || 0), tg = P.tgt;
|
||
const togo = tg ? Math.hypot(tg.x - P.x, tg.z - P.z) : 0;
|
||
for (let t = 0; t < 1.6; t += dt) {
|
||
const volley = b === 0;
|
||
const reach = TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1);
|
||
const legal = y <= (volley && rallyFeel() ? TUNE.smashMaxY : CONTACT.maxY) && (y >= CONTACT.minY || vy > 0)
|
||
&& !(volley && y < COURT.netH && ball.rx && !ball.rx.net);
|
||
const k = togo > 0.01 ? Math.min(1, sp * t / togo) : 0;
|
||
const xl = xLimit(), px = Math.max(-xl, Math.min(xl, P.x + (tg ? tg.x - P.x : 0) * k)), pz = P.z + (tg ? tg.z - P.z : 0) * k;
|
||
if (legal && Math.hypot(x - px, (z - pz) * 0.8) <= reach) {
|
||
const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel();
|
||
const sweet = TUNE.swingWindup + win / 2, band = win / 2 * TUNE.ringBand;
|
||
return { eta: t, sweet, band, gold: Math.abs(t - sweet) < band };
|
||
}
|
||
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
|
||
if (y <= 0 && vy < 0) { y = 0; vy = -vy * bY(); vx *= bXZ(); vz *= bXZ(); g = gAfter(g); b++; if (b > 1) return null; }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------- miss diagnostics ----
|
||
// Every player swing in a rally gets logged: HIT with quality, shot and how long the tap was, or
|
||
// MISS with the gate that rejected it and the ball's closest approach — and every point ends with
|
||
// a WON/LOST line naming why. Tap the scoreboard to see it; tap the log to COPY all of it (main.js).
|
||
// Inspect live via T.G.missLog; the latest miss reason can ride the HUD (TUNE.missHud).
|
||
// RALLY MODES since 2026-10-14 (Oliver: "bitte bau nochmal die Funktion ein die protokolliert warum
|
||
// ich einen Ball nicht bekommen habe"): it was gated on G.mode === 'tennis', and the match runs as
|
||
// 'splatter' under its interlude — so the Climb and the cup, the modes he plays, logged nothing.
|
||
const r2 = x => Math.round(x * 100) / 100;
|
||
const swingLog = () => G.mode === 'tennis' || rallyFeel();
|
||
|
||
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 > 200) G.missLog.shift();
|
||
if (entry.result !== 'HIT' && entry.result !== 'WON') G.lastMiss = { text: entry.reason, at: G.idleT };
|
||
}
|
||
|
||
// The line a POINT ends on, so a run of MISS/HIT lines reads as a story: who took it and why, and —
|
||
// when it was lost with no swing at all — how close the ball came. Called by pointTo (classic) and
|
||
// by resolveInterlude (the match), the two places every verdict passes.
|
||
export function logPoint(side, reason) {
|
||
if (!swingLog()) return;
|
||
const mine = ball.lastHit === 'P', n = G.rallyNear;
|
||
const why = reason === 'out' ? (mine ? 'your ball out' : 'their ball out')
|
||
: reason === 'double_bounce' ? (mine ? 'bounced twice on their side' : 'bounced twice on your side')
|
||
: reason === 'double_fault' ? 'double fault'
|
||
: reason === 'dead_roller' ? 'ball died'
|
||
: reason === 'passed_player' ? 'passed you'
|
||
: reason === 'unreachable_after_bounce' || reason === 'unreachable_in_flight' ? 'called early: out of his reach'
|
||
: reason === 'body_destroyed' ? 'body destroyed' : reason || 'unspecified';
|
||
const unswung = side === 'O' && !mine && n && !G.swung;
|
||
logSwing({ result: side === 'P' ? 'WON' : 'LOST',
|
||
reason: why + (unswung ? ` — never swung, it came within ${r2(n.d)}m at y=${r2(n.y)}m` : ''),
|
||
...(unswung ? { dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) } : {}) });
|
||
}
|
||
|
||
function logWhiff() {
|
||
const snaps = G.swingSnaps; G.swingSnaps = null;
|
||
if (!swingLog()) 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.lb)) reason = 'waited for the bounce — still in the air below the tape (from the back you let it bounce)';
|
||
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: reason + ` · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms`, 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),
|
||
...(P.tgt ? { runTo: r2(P.tgt.x) + ',' + r2(P.tgt.z) } : {}) }); // where the auto-run was taking him
|
||
}
|
||
|
||
// THE FOLLOW-THROUGH (step 4 (b), 2026-10-14). A tap a little EARLY on a baseline ball starts the swing
|
||
// while the ball is still in the air; the swing waits for the bounce (letBounce), and by then the
|
||
// groundstroke window has run out — a clean whiff at ~70 ms early, while a tap 120 ms LATE still met
|
||
// the ball. Before step 1 the same ball was volleyed in a 1.8x window, so it never came up. In the
|
||
// rally modes the swing now carries on for swingTail after its window and meets a ball arriving there
|
||
// at q 0.05: a weak short ball the opponent has to punish. Whether he does is what the ladder is.
|
||
const swingTail = isAI => !isAI && rallyFeel() ? TUNE.swingTail : 0;
|
||
// ...and the other half, without which the follow-through only made rallies endless: the CPU had NO
|
||
// way to win a point. It returned a floater exactly like a drive, its whiff rate is near zero past
|
||
// mid-ladder, and every point it took was a whiff of the player's — which no opponent dial touches, so
|
||
// the ladder could not climb. A sitter (the player's ball under punishBelow) is now PUT AWAY with a
|
||
// chance that IS the ladder: punishMin at the shambler to punishMax at the champion, classic by
|
||
// difficulty. Rally modes, solo only.
|
||
function punishes(ch) {
|
||
if (!rallyFeel() || G.net || ch.side === 'P' || (ball.pow ?? 1) >= TUNE.punishBelow) return false;
|
||
// the Climb's own progress (matchStep: its brisker bottom included), so a level-26 run is not filler
|
||
const k = G.diff ? (G.diff.punish ?? 0) : G.match ? TUNE.punishMin + (TUNE.punishMax - TUNE.punishMin) * Math.min(1, matchStep() / (LADDER - 1)) : 0;
|
||
return Math.random() < k;
|
||
}
|
||
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) * (isAI ? 1 : winLevel());
|
||
const t = ch.swingT;
|
||
if (t < TUNE.swingWindup || t > TUNE.swingWindup + win + (volley ? 0 : swingTail(isAI))) return; // the tail meets a BOUNCED ball
|
||
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;
|
||
// THE SMASH: the player's overhead reaches higher than a volley, out of the air, in the rally modes
|
||
const overhead = !isAI && volley && rallyFeel();
|
||
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > (overhead ? TUNE.smashMaxY : CONTACT.maxY)) return;
|
||
// ...unless he only reached it TURNING AND RUNNING (a lob whose overhead was out of reach when he read
|
||
// it): that one is met on the run, a defensive block like every run-down lob, however high
|
||
const smash = overhead && ball.y >= TUNE.smashMinY && !(ball.rx && ball.rx.lob && !ball.rx.over);
|
||
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
|
||
// planned from the back, the swing WAITS for the bounce instead of a weak pick-up — but only while
|
||
// the bounce will still land inside this window. If it will not, this is the last chance and the
|
||
// low ball is dug out as a half-volley (the below-the-tape cap prices it): a swing a touch early
|
||
// used to WHIFF here, which read as the game eating a good swing (Oliver's log, 2026-10-14).
|
||
// The deadline is the window AFTER the bounce (the groundstroke one, shorter than the volley window
|
||
// this swing is running on now) — judged against the long one, it waited and then expired on the
|
||
// bounce frame (his v367 log, 8.98 s).
|
||
if (letBounce()) {
|
||
const gg = ball.g || TUNE.gravity, tb = (ball.vy + Math.sqrt(ball.vy * ball.vy + 2 * gg * ball.y)) / gg;
|
||
if (t + tb < TUNE.swingWindup + TUNE.swingWindow * winLevel() + swingTail(isAI) - 0.03) return;
|
||
}
|
||
// 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);
|
||
// ...WITHOUT A CLIFF in the rally modes (step 4, 2026-10-14). The line above runs from the centre, so
|
||
// q was 1 inside perfectMs and 0.43 a millisecond outside it: a groundstroke's half-window is ~80 ms
|
||
// and PERFECT owns 45 of them. The volley's 1.8x window used to hide that (q 0.69 at the edge); step
|
||
// 1 made the rally groundstrokes, and a 30 ms early tap came back at q 0.6 (0.89 now). Same window,
|
||
// same PERFECT, same whiff edge — only the step between them is gone. It does NOT move the ring bot's
|
||
// win rate: points are decided by whiffs, not weak balls (see PROJECT.md, step 4's measurement).
|
||
const p = TUNE.perfectMs / 1000, half = win / 2;
|
||
if (rallyFeel() && !isAI && TUNE.qSeamless && half > p)
|
||
q = Math.max(0.05, 1 - (Math.abs(t - center) - p) / (half - p));
|
||
if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1;
|
||
if (t > TUNE.swingWindup + win) q = 0.05; // the follow-through: met, barely
|
||
ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite
|
||
// the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the
|
||
// finger is usually still down at contact, and the vector under it NOW is the aim the player
|
||
// means — the released flickAim only carries it when the ball arrives after the finger lifted.
|
||
const flick = isAI ? null : (G.swipe || takeFlickAim());
|
||
// SMART AIM (classic difficulty or the solo match foe): 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 smartChance = G.diff ? (G.diff.smart || 0) : G.match && !G.net ? (ch.foe?.smart || 0) : 0;
|
||
// Keep the old random sequence for classic and for foes without smart aim.
|
||
// THE SITTER IS PUNISHED: rolled first and only on a sitter, so every other ball keeps its old sequence
|
||
const kill = isAI && punishes(ch);
|
||
const smart = kill || isAI && (G.diff || smartChance) && Math.random() < smartChance;
|
||
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 ? { ...flick } : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; // a copy: the shot marks it (smash)
|
||
// THE STYLES (step 2): what this body DOES with the ball. A lob when you are at the net, a drop
|
||
// when you hang back and it is inside its own court, a moonball as a habit — otherwise the rally
|
||
// ball it always hit, carrying its spin. One roll, so the shares are exactly the table's.
|
||
const st = isAI && !kill ? styleOf(ch) : null;
|
||
if (st) {
|
||
const atNet = Math.abs(P.z - COURT.netZ) < TUNE.netFrom, r = Math.random();
|
||
const inside = Math.abs(ch.z - COURT.netZ) < COURT.netZ - 1.5;
|
||
if (atNet && r < (st.lob || 0)) { aim.az = 1; aim.soft = 1; }
|
||
else if (atNet) { aim.ax = P.x > 0 ? -1 : 1; aim.pass = true; aim.spin = st.spin; } // the PASS: at the line, away from him
|
||
else if (P.z > COURT.L - 0.3 && inside && r < (st.drop || 0)) { aim.az = 1; aim.soft = 0; }
|
||
else if (!atNet && r < (st.loft || 0)) { aim.az = 1; aim.soft = 0.85; }
|
||
else aim.spin = st.spin;
|
||
}
|
||
// A quality floor makes strong opponents sustain pressure, instead of only improving their
|
||
// luckiest returns. Classic and solo match traits own their bands; gore keeps its own floor.
|
||
const qMin = G.diff ? G.diff.qMin : G.match && !G.net ? (ch.foe?.qMin ?? TUNE.aiQualityMin) : TUNE.aiQualityMin;
|
||
if (isAI) q = Math.min(q, qMin + Math.random() * (Math.max(qCap, qMin) - qMin));
|
||
if (kill) q = Math.max(q, TUNE.punishQ);
|
||
const q0 = q; // (the log names whichever cap below took it down)
|
||
// a volley struck BELOW THE TAPE can only be blocked up: soft, short, little width. This is what
|
||
// the net costs — the passing shot dipped at your feet. ...and a DROP dug out at the ankles after
|
||
// its bounce comes back the same way: reaching it is not enough. Backspin balls only — any ball met
|
||
// on its first frame in reach is often low, and capping those made every rally ball a sitter.
|
||
// ...and SOFT backspin balls only (step 4): step 2's slicer hits every rally ball at spin 0.8, so this
|
||
// capped every low contact against him, his own included — invisible until a sitter got punished.
|
||
if (rallyFeel() && (ball.bounces === 0 && ball.y < COURT.netH || ball.soft && ball.spin < 1 && ball.y < TUNE.scoopY)) q = Math.min(q, TUNE.lowVolleyQ);
|
||
// ...and a LOBBED net man who ran it down comes back on his heels: a defensive ball, not a reply.
|
||
// That is the lob's real reward — rarely the winner itself, nearly always the point.
|
||
if (rallyFeel() && ball.rx && ball.rx.lob && !smash) q = Math.min(q, TUNE.lowVolleyQ); // ...unless he got UNDER it
|
||
if (kill && q >= TUNE.punishQ) aim.kill = true;
|
||
if (smash) { aim.smash = true; praisePop('SMASH!', ball.x, ball.y, ball.z); } // a put-away only if no cap took it down (a low volley is a block)
|
||
if (!isAI && swingLog()) {
|
||
G.swingSnaps = null; G.rallyNear = null;
|
||
const kind = aim.az > 0 && rallyFeel() ? (aim.mag === undefined || aim.mag >= TUNE.dropMax ? ' lob' : ' drop')
|
||
: aim.az < 0 ? ' deep' : '';
|
||
const sway = swayOf(aim), lane = Math.abs(sway) > 0.15 ? (sway < 0 ? ' left' : ' right') : '';
|
||
const cap = q >= q0 ? '' : ball.rx && ball.rx.lob ? ' · capped: lobbed, on your heels'
|
||
: ball.bounces === 0 ? ' · capped: volley below the tape' : ' · capped: dug out a drop';
|
||
// ...and WHICH WAY the timing missed: contact before the window's centre means the ball was
|
||
// already there — the swing came LATE; after it, the swing came EARLY
|
||
const off = Math.round((t - center) * 1000), timing = q0 >= 0.95 ? '' : off < 0 ? ` · late ${-off}ms` : ` · early ${off}ms`;
|
||
logSwing({ result: 'HIT', reason: `q=${r2(q)}${smash ? ' SMASH' : kind}${lane}${volley && !smash ? ' volley' : ''}${timing} · ${P.flick ? 'flick' : 'tap'} ${P.tap || 0}ms${cap}`,
|
||
d: r2(Math.hypot(dx, dz * 0.8)), y: r2(ball.y) });
|
||
}
|
||
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
|
||
// Solo tennis recovers during its own flight, at half chase speed. The serve pose finishes
|
||
// first; dead balls and the gore racket keep their existing hold.
|
||
const recovering = G.match && G.phase === 'rally' && ball.active && !ball.scored
|
||
&& !G.match.decided && !G.match.ending && ball.lastHit === 'O' && ball.vz > 0
|
||
&& !(rz.serveHold > 0);
|
||
// ...and while its own ball flies, is it RUSHING the net behind it? (serve and volley, an approach)
|
||
if (recovering) rz.rushing = ball.approach === 'O';
|
||
// ...and the sprint velocity dies WITH the chase. This return kept the last chase's vx/vz on
|
||
// the body, so the speed ghosts trailed a zombie that had already stopped ("teilweise bleiben
|
||
// die doppelten ghosts länger sichtbar") — the trail must read as motion, never as residue.
|
||
if (!incoming && !recovering) { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; return; }
|
||
// ANTICIPATION. He used to run at the ball's LIVE position, and against a serve that is a
|
||
// trap: for the first third of the flight the ball is still up by YOUR baseline, so he ran
|
||
// deep and straight while the ball was already leaving sideways, and only started sliding
|
||
// across once it was past him. Measured headless: a flush, hard serve into the corner of the
|
||
// box was returned 0 times out of 40 at every rung of the ladder — Oliver's "you can kill the
|
||
// first ten of them the moment you serve". He reads the BOUNCE now and runs at that instead.
|
||
// aiRead is how much of the read he gets: 1 would be a machine that is never wrong-footed, and
|
||
// being wrong-footed is most of what makes the rally a rally. Serves only in spirit but applied
|
||
// to every flight — the same lag was there in a rally, just hidden by the shorter distances.
|
||
const pred = incoming && ((G.match && ball.bounces === 0) || (ball.y > 0.35 && ball.vy < 3))
|
||
? predictLanding() : null;
|
||
// THE READ rides on aiRead: an EXPECTED ball (you repeated your habit) is read almost perfectly
|
||
// — spam feeds the machine — and a SURPRISE ball collapses the read back toward the old pure
|
||
// pursuit, which is exactly the wrong-footing aiRead exists to permit. Both scale with the rung
|
||
// (learnK), so the shambler never learns and the champion is the one you beat by breaking a
|
||
// habit you set on purpose. Dials in config's THE READ block; the streak lives in doHit.
|
||
const learn = learnK(rz);
|
||
const read = G.match && G.match.surprised ? TUNE.aiRead * (1 - TUNE.surpRead * learn)
|
||
: G.match && G.match.expected ? TUNE.aiRead + (TUNE.readAnticipate - TUNE.aiRead) * learn
|
||
: G.match ? TUNE.aiRead
|
||
// the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible)
|
||
: TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill();
|
||
// THE COURT HAS ITS LENGTH (match only): from the back he waits BEHIND the bounce for the ball to
|
||
// come down — standing ON it made him volley 90-100% of everything from the T — and a short ball
|
||
// pulls him in. At the net he holds his depth on the ball's line, unless it will clear his racket:
|
||
// that is a lob, and he turns and runs it down like a baseliner.
|
||
const plan = G.match && incoming ? rxPlan(rz) : null;
|
||
if (plan && plan.net && ball.vz < 0) {
|
||
const tc = (rz.z - ball.z) / ball.vz, gg = ball.g || TUNE.gravity;
|
||
if (tc > 0 && ball.y + ball.vy * tc - 0.5 * gg * tc * tc > CONTACT.maxY) { plan.net = false; plan.lob = true; }
|
||
}
|
||
// ...and AT THE NET he needs a moment to read the pass: a volleyer reacts, he does not pre-know.
|
||
// Without it a body with these legs covered every passing lane the instant the ball left.
|
||
if (G.match) G.aiReactT += dt;
|
||
// ...and a SOFT ball is read late from anywhere: until the strike it looks like any other swing,
|
||
// which is the whole reason a drop or a lob wins points in the real game. Rallies are untouched.
|
||
const reading = plan && (plan.net && G.aiReactT < TUNE.netReact || ball.soft && G.aiReactT < TUNE.softRead
|
||
|| ball.smash && G.aiReactT < TUNE.smashRead);
|
||
const c = plan && !plan.net ? bounceContact(plan.short) : null;
|
||
const netX = plan && plan.net ? ball.x + ball.vx * Math.max(0, (rz.z - ball.z) / ball.vz) : null;
|
||
const tgtX = c ? c.x : netX ?? (pred && pred.x);
|
||
const home = G.match ? rallyHome(rz) : HOME.O;
|
||
const aimX = recovering ? home.x : tgtX != null ? ball.x + (tgtX - ball.x) * read : ball.x;
|
||
// Read the landing depth immediately in a match, including rising returns. Blending with
|
||
// the ball's current depth would send every receiver towards the net before it arrives.
|
||
const aimZ = recovering ? home.z : c ? c.z : netX !== null ? Math.max(rz.z, COURT.netZ - TUNE.netStand)
|
||
: pred ? (G.match ? pred.z : ball.z + (pred.z - ball.z) * read) : ball.z;
|
||
const tz = Math.max(G.match ? -TUNE.rallyBehind : 1.5, Math.min(G.match ? COURT.netZ - 1.2 : COURT.svcFar, aimZ));
|
||
const dx = aimX - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz);
|
||
if (d > 0.15 && !reading) {
|
||
// 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.
|
||
// (the dials themselves live in rzChaseSpeed, which the rally-ending reach tests share)
|
||
// (recovery walks at half pace — but a body charging the net behind its shot SPRINTS)
|
||
const step = Math.min(rzChaseSpeed(rz) * (recovering && !rz.rushing ? 0.5 : 1) * 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;
|
||
// vz beside vx (drawZombie's speed ghosts read both), and MOVEMENT READS: a genuine sprint
|
||
// kicks the surface up behind it — one puff per runDustEvery metres, the same per-court dust
|
||
// the ball's bounce already throws, so the effect can never wear the wrong colour
|
||
rz.vz = dz / d * step / dt;
|
||
if (TUNE.trailMin && step / dt > TUNE.trailMin
|
||
&& (rz.dustAcc = (rz.dustAcc || 0) + step) > TUNE.runDustEvery) {
|
||
rz.dustAcc = 0; dustPuff(rz.x, rz.z, 2, 0.6);
|
||
}
|
||
} else { rz.vx = 0; rz.vz = 0; if (rz.pose === 'run') rz.pose = 'idle'; }
|
||
// THE WOUNDS PLAY. Score-is-the-body was a picture until now: it lost an arm and swung exactly
|
||
// as far, lost its head and missed exactly as often. Both dials read the same racketStage the
|
||
// SKINS do, so what you see it lose is what it loses. Match only — see config.matchWound.
|
||
const wnd = G.match ? matchWound(rz) : { reach: 1, flub: 1 };
|
||
if (incoming && (!G.match || ball.z < COURT.netZ) && rz.swingCool <= 0 && rz.swingT < 0
|
||
&& Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach * wnd.reach
|
||
&& ball.y < CONTACT.maxY && !letBounce()
|
||
&& (G.match ? ball.y >= CONTACT.minY || ball.vy > 0 : 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, matchStep()) * (rz.foe?.flub || 1) * wnd.flub
|
||
: TUNE.racketFlub * (1 - TUNE.duelFlubCut * duelSkill()))
|
||
// THE READ: a wrong-footed racket reaches the ball late even when the legs got there —
|
||
// the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this
|
||
// body had actually learned. The champion's near-zero flub is exactly what this opens.
|
||
+ (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0);
|
||
if (Math.random() < flub) {
|
||
recordEvent('opponent_whiff', { chance: flub, chaseSpeed: rzChaseSpeed(rz), reach: TUNE.aiReach * wnd.reach });
|
||
if (swingLog()) logSwing({ result: 'THEM', reason: 'whiffed it' });
|
||
rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder)
|
||
}
|
||
else {
|
||
const qCap = G.match && !G.net ? (rz.foe?.qMax ?? TUNE.racketQMax)
|
||
: TUNE.racketQMax + TUNE.duelQBonus * duelSkill();
|
||
rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
|
||
trySwing(rz, true, qCap); rz.swingT = -1; rz.swingCool = 0.3;
|
||
}
|
||
// the lean is SPENT at the racket, either way — matchReady clears it when a point ends unmet
|
||
if (G.match) G.match.surprised = G.match.expected = false;
|
||
}
|
||
}
|
||
|
||
// 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++;
|
||
// the FLOOR rides the level (the leagues: the level is you) — the relaxed ceiling does not
|
||
return Math.max(TUNE.meleeCoolMin * bonus().melee,
|
||
TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd));
|
||
}
|
||
|
||
export function keyPressed(code, lead = 0, flick = false) {
|
||
// 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 * bonus().parry)) {
|
||
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;
|
||
}
|
||
// THE DOUBLE TAP IS HIS LEGS (2026-10-14, Oliver: "das Vorlaufen und Zurücklaufen sollte durch einen
|
||
// Doppeltipp ausgelöst werden"). While his OWN ball is flying there is nothing to hit, so a tap there is
|
||
// not a swing: two inside dblTap are the move — in to the net from the back, back from the net. An
|
||
// incoming ball keeps every tap a swing, so the two meanings never share a moment. (v374 put this on the
|
||
// flick's length; the shot and the run are separate decisions now.)
|
||
if (rallyFeel() && G.phase === 'rally' && ball.active && ball.lastHit === 'P' && !flick) {
|
||
if (G.moveTapT > 0) { G.moveTapT = 0; moveCall(); } else G.moveTapT = TUNE.dblTap;
|
||
return;
|
||
}
|
||
if (G.phase === 'rally' && P.swingT < 0) {
|
||
recordEvent('swing', { window: TUNE.swingWindow * winLevel(), assist: assistK(), lead });
|
||
// A TAP COUNTS FROM THE MOMENT THE FINGER LANDS (rally modes). A tap is only known to BE a tap
|
||
// when the finger lifts — it could still turn into a flick — so it used to swing ~100-150 ms
|
||
// after the player meant it. Inside step 1's groundstroke window that collapsed the contact
|
||
// quality of a tap made exactly on the gold ring from 0.82 to 0.09 (Oliver, 2026-10-14: "trotz
|
||
// gutem Timing nicht mehr bekommen"). input.js hands the touch's age in as `lead`, and the swing
|
||
// clock starts that far along: the timing is judged from touch-down, the ball still has to be
|
||
// there for the contact.
|
||
// A FLICK is NOT back-dated by default (TUNE.flickLead 0): his v367 log showed every 62-64 ms flick
|
||
// landing 76-92 ms EARLY once v366 timed flicks from touch-down — a flick is timed by its swipe,
|
||
// not by the finger landing. The touch's age is still logged for both gestures.
|
||
P.swingT = rallyFeel() ? (flick ? Math.min(lead, TUNE.flickLead) : lead) : 0; P.pose = 'swing'; P.poseT = 0; G.swung = true;
|
||
P.tap = Math.round(lead * 1000); P.flick = flick; // ...and the log says which gesture, and how long it took to fire
|
||
}
|
||
}
|
||
|
||
// In to the net, or back from it: the double tap's one meaning is "the other end of my half". At the net
|
||
// (or already running in) it is the way back; anywhere else it is the way in. rallyHome reads the flags
|
||
// while his ball flies, the run goes at full pace, and his reply is planned from wherever he has got to.
|
||
// It is a STANCE, not one ball's run (Oliver on v376: "wenn man doppeltippt, geht er minimal vor"): his own
|
||
// ball's flight covers 4-9 m of the 10 to the net, his next ball was met from mid-court, outside the net
|
||
// zone, and the recovery after it walked him straight back to the baseline. P.post holds until the next
|
||
// double tap, a lob he has to run down, or the point's end.
|
||
function moveCall() {
|
||
const goingIn = atNetHome(P);
|
||
P.post = goingIn ? 'base' : 'net';
|
||
ball.approach = goingIn ? null : 'P';
|
||
ball.retreat = goingIn ? 'P' : null;
|
||
popText(P.x, 0, P.z + TUNE.praiseBehind, goingIn ? 'BACK' : 'TO THE NET', UI.warm, 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) {
|
||
recordFrame(dt);
|
||
if (G.moveTapT > 0) G.moveTapT -= dt; // the double tap's window (keyPressed)
|
||
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) {
|
||
const px0 = P.x, pz0 = P.z; // MOVEMENT READS: the frame's real travel, measured after the clamps
|
||
if (swingLog() && 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
|
||
// ...and lobbed at the net he backpedals, exactly as slowly as the CPU does (TUNE.lobTurn)
|
||
// HIS OWN WAY IN AND OUT runs at full pace (the attack's follow-in, the lob's retreat), and while
|
||
// his attack flies he is RUSHING: the reply is planned as a net man's from wherever he has got to
|
||
// committed to the net he is a net man for every ball until he reaches it (a double tap behind his serve is serve and volley)
|
||
if (P.post === 'base' && Math.abs(P.z - COURT.netZ) > TUNE.netFrom + 0.5) P.post = null; // back: the old rules again (a short ball still pulls him in)
|
||
P.rushing = P.post ? P.post === 'net' : ball.lastHit === 'P' ? ball.approach === 'P' : P.rushing;
|
||
const ownRun = !chasing && rallyFeel() && (P.post || ball.lastHit === 'P' && (ball.approach === 'P' || ball.retreat === 'P'));
|
||
let rate = TUNE.playerSpeed * aK * dt * (chasing || ownRun ? 1 : 0.55) * (chasing && ball.rx && ball.rx.lob ? TUNE.lobTurn : 1);
|
||
// ON HIS HEELS: a put-away of his own sitter is read late (step 4 (b)). Without this the auto-run
|
||
// started on the strike's frame with a perfect read and covered the whole court — no put-away ever
|
||
// won a point, and the CPU still had no way to take one
|
||
const key = ball.lastHit + (chasing ? 'c' : 'r');
|
||
if (!chasing) P.chase = null;
|
||
else if (!P.chase || P.goalKey !== key) P.chase = { x: P.x, z: P.z, t: 0, lost: 0, plan: null }; // where this chase began
|
||
if (chasing && ball.heels > 0) { ball.heels -= dt; rate = 0; if (P.chase) P.chase.lost += dt; }
|
||
else if (P.chase) P.chase.t += dt; // the time he has had to run, for intercept's reach
|
||
// 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.
|
||
// ...and the plan is LATCHED for the chase: the flight is fixed the moment the ball is struck, so the
|
||
// point chosen first stays the answer while his legs still make it in time (net of any time he stood:
|
||
// a split-step, a put-away's heels). Re-asked every frame, every margin in intercept — a bounce that
|
||
// peaks on a height floor, a point on the back boundary — flipped the plan by metres as the 10 ms
|
||
// sampling grid slid against the frame: the shake Oliver saw. Hysteresis there only moved the margin.
|
||
const c = P.chase, spdP = TUNE.playerSpeed * aK;
|
||
const latched = c && c.plan && c.plan.ti !== undefined
|
||
&& Math.hypot(c.plan.x - c.x, c.plan.z - c.z) / spdP <= c.plan.ti - (c.lost - c.plan.lost0) + 0.02;
|
||
const raw = chasing ? (latched ? c.plan : (c ? (c.plan = { ...intercept('P'), lost0: c.lost }) : intercept('P')))
|
||
: G.mode === 'splatter' && !G.match ? { x: P.x, z: HOME.P.z }
|
||
: rallyFeel() ? rallyHome(P) : HOME.P;
|
||
// THE RUN, SMOOTHED (2026-10-14, Oliver: "wenn der Autoplay den Ball versucht zu bekommen, zittert der
|
||
// Player schnell hin und her"). intercept asks every frame for the EARLIEST point he reaches in time,
|
||
// and his own step moves that point: the target jumped ~10 cm a frame (p90 0.3 m) and flipped across
|
||
// him, start/stop at the deadzone's edge, dozens of reversals a minute. It now EASES toward each plan
|
||
// (runSmooth); a new shot or a re-plan further than runSnap (the lob read) is still taken at once.
|
||
const jump = P.goal ? Math.hypot(raw.x - P.goal.x, raw.z - P.goal.z) : Infinity;
|
||
if (!P.goal || P.goalKey !== key || jump > TUNE.runSnap) P.goal = { x: raw.x, z: raw.z };
|
||
else { const k = Math.min(1, dt * TUNE.runSmooth); P.goal = { x: P.goal.x + (raw.x - P.goal.x) * k, z: P.goal.z + (raw.z - P.goal.z) * k }; }
|
||
P.goalKey = key;
|
||
const tgt = P.goal;
|
||
P.tgt = tgt; // the timed gold ring (ringCue) predicts the contact from where this run ends
|
||
const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz);
|
||
// deadzone WITH HYSTERESIS: start at 0.35, and once running, carry on to runStop — a target hovering
|
||
// at the edge no longer toggles him between a step and a stand every other frame
|
||
P.running = d > (P.running ? TUNE.runStop : 0.35);
|
||
if (P.running) { // 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);
|
||
// MOVEMENT READS: the player's own travel this frame, measured AFTER the clamps so a body
|
||
// pinned against the bound reads as standing (drawChar's speed ghosts read vx/vz). A real
|
||
// sprint — the auto-run chase, the horde steer — kicks the same per-court dust the bodies do.
|
||
P.vx = (P.x - px0) / dt; P.vz = (P.z - pz0) / dt;
|
||
const pSpd = Math.hypot(P.vx, P.vz);
|
||
if (TUNE.trailMin && pSpd > TUNE.trailMin
|
||
&& (P.dustAcc = (P.dustAcc || 0) + pSpd * dt) > TUNE.runDustEvery) {
|
||
P.dustAcc = 0; dustPuff(P.x, P.z, 2, 0.6, skillDust());
|
||
}
|
||
}
|
||
// ...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.
|
||
// vx/vz die with the mover too, or the speed ghosts would trail a body that stopped dead.
|
||
else { P.vx = 0; P.vz = 0; if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } }
|
||
// THE GOLD RING, STEADY (Oliver, 2026-10-14: "der Treffer-/Zielkreis zittert jetzt manchmal").
|
||
// ringCue re-solves every frame against where the auto-run is taking him, and a target that shifts
|
||
// a few centimetres moved the predicted moment by a frame or two — the ring's size twitched and its
|
||
// gold blinked. The drawn cue COUNTS DOWN on its own clock and only eases toward each fresh solve
|
||
// (TUNE.ringSmooth), and a frame with no solve holds it instead of dropping it.
|
||
if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && (rallyFeel() || G.mode === 'tennis')) {
|
||
const c = ringCue();
|
||
if (c && !G.ring) G.ring = { ...c };
|
||
else if (c) { const e = G.ring.eta - dt; G.ring = { ...c, eta: e + (c.eta - e) * Math.min(1, dt * TUNE.ringSmooth) }; }
|
||
else if (G.ring && (G.ring.hold = (G.ring.hold || 0) + dt) < 0.12) G.ring.eta -= dt;
|
||
else G.ring = null;
|
||
if (G.ring) G.ring.gold = Math.abs(G.ring.eta - G.ring.sweet) < G.ring.band;
|
||
} else G.ring = null;
|
||
// 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 (swingLog() && 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, lb: letBounce(),
|
||
// the reach trySwing really tests — the match's longer racket included, or a near miss reads wide
|
||
r: TUNE.reach * (volley ? VOLLEY.reach : 1) * (rallyFeel() ? TUNE.matchReach : 1),
|
||
w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) * winLevel() });
|
||
}
|
||
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)
|
||
* winLevel(); // the clock must outlive the widened window or the level's gift is dead time
|
||
if (P.swingT > TUNE.swingWindup + Math.max(win, TUNE.swingWindow * winLevel() + swingTail(false)) + 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
|
||
&& !G.story) // the story has no duel at all
|
||
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') : rallyFeel() ? rallyHome(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 && !letBounce();
|
||
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', 'unreachable_in_flight');
|
||
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 * bY();
|
||
ball.vx *= bXZ(); ball.vz *= bXZ();
|
||
ball.g = gAfter(ball.g); // the dip ends at the bounce: a topspin ball kicks UP now (gAfter)
|
||
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, 'dead_roller');
|
||
} 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.
|
||
// IN THE MATCH "past you" means past your REACH (2026-10-14, Oliver's v366 log): since step 1 a
|
||
// baseliner waits up to rallyBehind BEHIND his line, so the old line — 0.3 m behind the baseline —
|
||
// called the point while the ball was still on its way to his racket. Every deep topspin ball the
|
||
// auto-run met deep was a "passed you" with no swing. The duel keeps the old line.
|
||
const passZ = G.match ? Math.max(COURT.L + 0.3, P.z + TUNE.reach * TUNE.matchReach + 0.3) : COURT.L + 0.3;
|
||
if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > passZ) {
|
||
ball.active = false;
|
||
if (G.interlude) resolveInterlude('O', 'passed_player'); // 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);
|
||
tickStory(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();
|
||
}
|
||
}
|
||
}
|