Oliver: "wenn der Autoplay den Ball versucht zu bekommen, zittert der Player schnell hin und her". Measured: the run's target flipped by metres every other frame. intercept samples the flight every 10 ms against a 16.7 ms frame, so any candidate on a margin (a low bounce peaking on the 0.45 m floor, a point on the back boundary, a dying drop) passed one frame and failed the next. Frames with no candidate sent him home and back. 9-62 reversals inside 200 ms per five minutes. The chase now latches its plan: the flight is fixed at the strike, so the first point stays the answer while his legs still make it in time, net of any time he stood (split-step, heels). Reach is judged from where the chase began, and the target eases with a start/stop hysteresis. After: no reversals at any rung, timing unchanged. He wastes no steps, so he is a little stronger, mostly at the bottom of the ladder. New test; v375. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2761 lines
189 KiB
JavaScript
2761 lines
189 KiB
JavaScript
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||
|
||
import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, 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 } 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;
|
||
// THE PLAYER'S OWN WAY IN AND OUT (2026-10-14, Oliver: "Steuerung ans Netz gehen"). A LONG up-flick
|
||
// is the attack: the deep ball, and he follows it in — even onto a man at the net, it is his call.
|
||
// A long DOWN-flick is the lob, and he goes back behind it. Gesture length only, never context: the
|
||
// 2026-08-22 lob taught that a gesture whose meaning depends on where the opponent stands is noise.
|
||
const longFlick = aim && (aim.mag === undefined || aim.mag >= TUNE.attackMin);
|
||
if (ch.side === 'P' && rallyFeel() && aim && aim.az < 0 && !aim.smash && longFlick) ball.approach = 'P';
|
||
ball.retreat = ch.side === 'P' && rallyFeel() && soft !== null && soft >= 1 ? 'P' : null;
|
||
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.
|
||
if (G.runGames === TUNE.duelFirst ||
|
||
(G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
|
||
G.duelDue = true;
|
||
// ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from
|
||
// proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks
|
||
// everything else there — a boss must not walk on into a standing wave.
|
||
if (G.runGames % TUNE.bossEvery === 0)
|
||
G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a
|
||
// long stretch with no clear skips a chapter rather than queueing one, which is intended
|
||
|
||
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
|
||
if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted
|
||
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
|
||
const i = side === 'P' ? 0 : 1;
|
||
if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card
|
||
else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games
|
||
else G.afterClear = 'game'; // even game: a beat, then the next wave
|
||
G.clearWinner = side;
|
||
// The racket zombie is DEMOTED: he used to appear on every single game you won, which is exactly
|
||
// why he read as "the boss" and why a real boss would not have stood out next to him. He is an
|
||
// occasional opponent now — every racketEvery games — and the chapter boss is the wall.
|
||
if (side === 'P' && G.runGames % TUNE.racketEvery === 0 && !zombies.some(z => z.variant === 'r')
|
||
&& !G.bossFight)
|
||
spawnRacketZombie();
|
||
// the rogue ball machine gets wheeled out on the same kind of schedule, capped on court
|
||
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
|
||
&& zombies.filter(z => z.variant === 'm').length < machCap() && !G.bossFight)
|
||
spawnMachine();
|
||
// ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp),
|
||
// because the late game is where the rack has to carry you. Still one at a time: two glowing
|
||
// pink things on court would out-shout everything the palette uses to say "this one is dangerous".
|
||
if (side === 'P' && G.runGames % barrelGap() === 0
|
||
&& !zombies.some(z => z.variant === 'o') && !G.bossFight)
|
||
spawnBarrel();
|
||
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
|
||
// play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear()
|
||
// fires (see update()). If the winning shot already emptied the court, proceed at once.
|
||
if (zombies.some(blocksClear)) {
|
||
G.clearing = true; ball.active = false;
|
||
G.flash = { text: 'CLEAR THE COURT', t: 1.8 };
|
||
G.phase = 'over'; G.overT = TUNE.gameBeatT; // a short beat to register the game, then the serve loop resumes for the mop-up
|
||
} else proceedAfterClear();
|
||
} else if (!quiet || G.idleT - G.lastScoreT > 2) {
|
||
voice(scoreKey(), 0.45); G.lastScoreT = G.idleT;
|
||
if (side === 'P' && !quiet) cheer(0.7, 0.25);
|
||
}
|
||
}
|
||
|
||
// (proceedAfterClear and every start*/end* mode transition live in beats.js — one file owns who
|
||
// has the court. splatPoint above only ARMS the schedule latches; beats.js decides when they fire.)
|
||
|
||
// ---- the courtside rack. RACK_MAX + 1 slots, and only slot 0 is fixed: it is BALL (infinite), or
|
||
// an overflow weapon riding it temporarily. Slots 1..RACK_MAX are what you have picked up, in order.
|
||
// EVERYTHING READS THROUGH THESE FOUR, so nothing else has to know how the rack is stored — and
|
||
// nothing else may hardcode how MANY slots there are. `slotOf` exists because endBurst did exactly
|
||
// that ("[0,1,2,3,4].find(...)"), so the moment the rack grew past five, a burst weapon sitting in
|
||
// slot 5..9 could never be found and never spent: beacon, flamer and rocket sat at their pickup
|
||
// count forever. One accessor, one place that knows the range.
|
||
const slotEntry = i => (i === 0 ? G.temp : G.rack[i - 1]) || null;
|
||
export const slotId = i => slotEntry(i)?.id || (i === 0 ? 'ball' : null);
|
||
export const slotStock = i => { const e = slotEntry(i); return e ? e.n : (i === 0 ? Infinity : 0); };
|
||
const slotOf = id => { // -1 if you are not carrying it
|
||
for (let i = 0; i <= RACK_MAX; i++) if (slotId(i) === id) return i;
|
||
return -1;
|
||
};
|
||
export function selectWeapon(i) {
|
||
if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only
|
||
// A HORDE is the one place the rack stays locked: your finger is steering the man, the racket
|
||
// fires itself, and there is no gesture left to spend on a tin. Everything else can be switched
|
||
// out of, including mid-burst — see below.
|
||
if (G.horde) return;
|
||
if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent
|
||
// A BURST IS NOT A COMMITMENT. Committing to a burst tin used to lock the rack for its whole
|
||
// window — eleven seconds of beacon lobs while a late-game crowd walks into you is a death you
|
||
// can watch coming and do nothing about, which is exactly what Oliver hit. Tapping another slot
|
||
// ABORTS it: the tin is spent (you did fire it) and the serve loop comes straight back, the same
|
||
// handover updateZombies does when the window ends on its own. Both input paths land here — the
|
||
// rack tap and the 1-0 keys — so the abort lives here rather than in either of them.
|
||
if (G.mgT > 0) {
|
||
// ...but spending a tin can EMPTY it, and spendSlot splices an empty entry out, which shifts
|
||
// every slot above it down one. A tap is at a slot; what you meant was the weapon you could see
|
||
// in it. So resolve to the id first and look it up again afterwards — otherwise aborting a
|
||
// beacon in slot 1 to reach the rocket in slot 4 hands you whatever slid into slot 4.
|
||
const want = slotId(i);
|
||
G.mgT = 0; endBurst(); setupPoint();
|
||
const j = slotOf(want);
|
||
i = j >= 0 ? j : 0; // the tin we just spent WAS the one tapped: fall back to BALL
|
||
}
|
||
G.weapon = i; // temp has just been cleared, and then it is BALL again)
|
||
sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud
|
||
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.
|
||
const rallyHome = ch => ({ x: 0, z: (Math.abs(ch.z - COURT.netZ) < TUNE.netFrom && ball.retreat !== ch.side) || ball.approach === ch.side
|
||
? 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; // no overhead in reach: he runs it down
|
||
// ...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;
|
||
}
|
||
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
|
||
}
|
||
}
|
||
|
||
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.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
|
||
if (ball.lastHit === 'P') P.rushing = ball.approach === 'P' && !G.wasServe;
|
||
const ownRun = !chasing && rallyFeel() && 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)
|
||
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);
|
||
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();
|
||
}
|
||
}
|
||
}
|