// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. import { TUNE, UI, BOSS_ART, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS } from './config.js'; import { P, O, ball, score, crowd, G, court, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. // `ty` is the height it arrives AT — ball-on-the-grass by default, but a splatter serve aims at a // shambler's chest, and solving to the ground under one is what made the ball strike the ankles. export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity, ty = 0.08) { const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s; return { vx: dx / T, vz: dz / T, vy: (ty - y0 + 0.5 * g * T * T) / T }; } export function predictLanding() { // first-bounce point, closed form — exact, so the ring never jitters const g = ball.g || TUNE.gravity; const disc = ball.vy * ball.vy + 2 * g * ball.y; if (disc < 0) return null; const t = (ball.vy + Math.sqrt(disc)) / g; // time until y = 0 return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t, t }; // t rides along: the landing ring contracts on it } // splatter FREE-AIM (slingshot). The pull is a RAY, not a landing point: its DIRECTION picks the // lane you fire down, its LENGTH is power — and nothing else. Pulling further does NOT reach // further, which is the whole fix: a shambler at the far baseline costs the same short pull as // one on top of you, so the gesture always fits on the screen. The first body standing in the // lane is the target; the cone widens with range so distant lanes stay pickable. const pullFrac = mag => Math.max(0, Math.min(1, (mag - TUNE.pullMin) / (TUNE.pullFull - TUNE.pullMin))); export const pullQuality = mag => TUNE.pullQMin + pullFrac(mag) * (1 - TUNE.pullQMin); // pullQuality inverted: hitServe is handed a quality, not a drag, and the band it leaves behind is // drawn at the same weight the live preview had. const qualityFrac = q => Math.max(0, Math.min(1, (q - TUNE.pullQMin) / (1 - TUNE.pullQMin))); // A pull that STOPS in the band at the top of the draw, rather than merely reaching it. See // TUNE.perfectBand for why the old "quality == 1" test praised almost every serve. export const pullPerfect = mag => mag >= TUNE.pullFull && mag <= TUNE.pullFull * TUNE.perfectBand; // The umpire has never once acknowledged the massacre, and he is not about to start now: a clean // shot gets the same line it would get at Wimbledon. Cycled rather than random — the same word twice // running is what made one word read as a stuck HUD element rather than as praise. const PRAISE = ['PERFECT', 'TEXTBOOK', 'IMMACULATE', 'OH, LOVELY', 'UNPLAYABLE', 'SHOT OF THE DAY']; let praiseN = 0; // ...and he does not say it every time. Same cooldown model as the asides (aside()): once you have // learned the band you hit it constantly, and a HUD that talks on every other serve is noise // whatever the word is. The bright click in the pull is the reliable feedback; this is the garnish. // Returns the word (or null, on cooldown) — the CALLER decides where and when it appears, because // the word must land at the IMPACT, not at the release (Oliver: "the reactions are playing after // shooting the ball, not when the ball hits"). A serve arms it on ball.praise and the contact/ // bounce pops it; a rally hit pops it at the racket, where contact and praise are the same moment. function praiseWord() { if (G.praiseT > 0) return null; G.praiseT = TUNE.praiseEvery; return PRAISE[praiseN++ % PRAISE.length]; } // Court dust: a kicked-up puff at ground level, tinted per surface — chalk on clay, flecks on the // grass, grit on the slab. Squarely inside the "blood, fire, smoke" procedural exemption; the ball's // bounce and the sidestep both kick it, which is what pins fast feet and fast balls to the ground. const DUST_COL = { grass: '#8fa06a', clay: '#c9895a', hard: '#8494a4' }; function dustPuff(x, z, n = 5, sp = 1) { for (let k = 0; k < n; k++) parts.push({ x: x + (Math.random() * 2 - 1) * 0.15, y: 0.05, z: z + (Math.random() * 2 - 1) * 0.1, vx: (Math.random() * 2 - 1) * 1.2 * sp, vy: 0.5 + Math.random() * 0.9 * sp, vz: (Math.random() * 2 - 1) * 1.2 * sp, t: 0.25 + Math.random() * 0.2, spark: DUST_COL[court.id] || '#98948a' }); } export function flickTarget(ch, aim) { // exported: render draws the live reticle with it const q = pullQuality(aim.mag), f = pullFrac(aim.mag); if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z let zt = null, near = 1e9, bestErr = 1e9; for (const zb of zombies) { const dx = zb.x - ch.x, dz = zb.z - ch.z; const along = dx * ax + dz * az; // how far down the lane it stands if (along <= 0) continue; // behind the shot const cone = TUNE.zSnap + TUNE.aimCone * along; const err = Math.abs(dx * az - dz * ax) / cone; // 0 = dead on the ray, 1 = the cone's edge if (err > 1) continue; // outside the cone entirely // Pick the body you are actually POINTING AT, not merely the nearest one that happens to fall // inside the cone. "First in the lane wins" let a close shambler off to one side steal a shot // clearly aimed past it — the magnet Oliver felt. Range only breaks ties between equally // well-aimed targets (TUNE.aimTie of slack), so a deliberate far shot still beats a sloppy // near one while a genuine straight-ahead lane still eats the nearest body first. const tie = TUNE.aimTie; if (err < bestErr - tie || (Math.abs(err - bestErr) <= tie && along < near)) { bestErr = Math.min(bestErr, err); near = along; zt = zb; } } const R = zt ? near : TUNE.aimReach; // empty lane: the ball just flies the full length of it const tx = Math.max(-6.5, Math.min(6.5, ch.x + ax * R)); const tz = Math.max(1, Math.min(24.5, ch.z + az * R)); return { tx, tz, zt, q, f }; } // splatter tap/lane aiming (no flick): the shot locks onto the best-matching zombie // anywhere on the court — including your own side of the net. function zombieTarget(aim) { if (!zombies.length) return null; const none = !aim || (!aim.ax && !aim.az); if (none) return null; // no direction, no lock. Free predictive auto-aim was the reason a // bare rhythm of taps could clear the court without any aiming at all. let best = null, bs = 1e9; for (const zb of zombies) { let s = Math.abs(zb.x - (P.x + aim.ax * 4)); // lane relative to where YOU stand if (aim.az < 0) s += zb.z * 0.35; // flick up: prefer the far ones if (aim.az > 0) s += (COURT.L - zb.z) * 0.35; // flick down: the close ones on your side if (s < bs) { bs = s; best = zb; } } return best; } function doHit(ch, quality, aim) { // aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short) const toFar = ch.side === 'P'; if (ch.side === 'P') G.passStreak = 0; // a landed swing ends the tap-to-hit lesson // snap the ball to the racket at the contact frame so the hit visually connects ball.x = ch.x + ch.facing * 0.5; ball.z = ch.z + (toFar ? -0.3 : 0.3); const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); let tx, tz; let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim ? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null; if (ft && ft.zt) { const zt = ft.zt; tx = zt.x + err() * 0.4; const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s; tz = Math.min(23, zt.z + zt.v * T0) + err() * 0.3; // lead the shambler's walk } else if (ft && ft.tx !== undefined) { tx = ft.tx + err() * 0.4; tz = ft.tz + err() * 0.3; // no magnet: the ball goes where you flicked } else { tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; } tx = Math.max(-5, Math.min(5, tx)); // topspin: quality shots dive under extra effective gravity, which is what lets real // pace arc over the net and still land in — the core tennis trade-off const g = TUNE.gravity * (1 + quality * TUNE.spinFactor); let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); // low contact → trade pace for loft until the net is cleared; good timing gets a // comfortable margin, sloppy timing keeps some tape risk. Same crossesNet guard as serveArc's, // for the same reason: a splatter volley at a body on your own side has no tape to clear, and // without this it was lobbed at a fraction of its pace. (In tennis the ball always crosses.) const margin = 0.06 + 0.22 * quality; const crossesNet = (ball.z - COURT.netZ) * (tz - COURT.netZ) < 0; for (let i = 0; crossesNet && i < NET_LOFT_TRIES; i++) { const tN = (COURT.netZ - ball.z) / v.vz; const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN; if (tN <= 0 || yN >= COURT.netH + margin) break; s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g); } ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.lastHit = ch.side; ball.bounces = 0; G.wasServe = false; // a serve volleyed back pre-bounce must not fault-check the return ball.pow = quality; // carried pace: zombies splatter only above zSplatQ ball.kills = 0; // fresh shot: multi-kill streak restarts ball.bomb = false; ball.flame = false; ball.wid = 'ball'; // only a consuming serve arms these; any other hit clears them ball.praise = null; // a re-hit is a new shot: a stale armed word must not pop on ITS impact if (ch.side === 'P') { // shot marker: where this will land — red means it's going out // TENNIS ONLY. It answers "did that land in?", and splatter does not ask: `out` could never be // true there, so it was a permanent yellow ring on the grass carrying no information — and // sitting under the shambler for 1.3 s, which is longer than the gap before you aim again. // That ring, not the new mark, is the circle that seemed to live under their feet. if (G.mode === 'tennis') { const ok = Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ; G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 }; } G.aiReactT = 0; // CPU hasn't read the shot yet } sfx(ch.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded racket strike, per side if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs) G.hitstop = 0.05; pock(0.5, 1600); const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact if (w) popText(ball.x, ball.y, ball.z, w); } ch.pose = 'swing'; ch.poseT = 0; } // ----------------------------------------------------------------- serve ---- function tossBall(sv) { ball.active = true; ball.bounces = 0; ball.collected = false; // The player is right-handed: he tosses with his LEFT hand (viewer's left) and hits with the // racket on the viewer's right, so the ball goes up almost over his head — a big offset either way // lands it on the wrong side of one of those two frames. The opponent is drawn from the front and // keeps the reach he was drawn with. ball.x = sv.x + (sv.side === 'P' ? 0.12 : -0.35); ball.z = sv.z; ball.y = TOSS.y0; ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity; G.phase = 'toss'; } function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95 // how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target // sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35). const NET_LOFT_TRIES = 12; // ---- ONE SOLVER for the slingshot serve. The reticle draws the arc the ball will actually fly, // so the targeting and the ballistics below are shared with hitServe rather than reimplemented. // The moment a preview and a shot come from two copies of this maths they drift, and then the // preview is worse than nothing — it lies with confidence. // dragServe snaps the ball to a FIXED contact before striking, which is the whole reason the arc // is previewable: the launch point is known while you are still pulling. Both live here. export const dragContact = () => ({ x: P.x + 0.12, y: 2.2, z: P.z }); // Where a splatter serve is aimed. `ex` is the aim error: hitServe passes the real random one, // the preview passes 0, so the arc you see is the shot you MEANT rather than the shot you got. // Returns null when this is not a free-aim/lane serve at all (the tennis serve keeps its box). function splatServeTarget(sv, aim, s, x0, z0, ex = 0) { if (G.mode === 'tennis' || G.interlude) return null; // the duel serves like tennis const ft = aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }; if (ft.zt) { const T0 = Math.hypot(ft.zt.x - x0, ft.zt.z - z0) / s; // ty: aim at the CHEST of whatever this is, so the ball arrives on the body it is going to // wound. Scaled off the same height render draws it at, so a brute is struck on the brute. return { tx: ft.zt.x + ex, tz: Math.min(23, ft.zt.z + ft.zt.v * T0), // lead the walk ty: zombieH(ft.zt) * Z_CHEST, ft }; } if (ft.tx !== undefined) return { tx: ft.tx, tz: ft.tz, ty: 0.08, ft }; // empty lane: onto the grass return null; } // The launch itself: topspin gravity, then trade pace for arc until the net is genuinely cleared. // A shambler just the other side of the tape is only reachable on a loft. function serveArc(x0, y0, z0, tx, tz, quality, ty = 0.08) { let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; const g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips let v = solveShot(x0, y0, z0, tx, tz, s, g, ty); // THERE IS ONLY A NET TO CLEAR IF THE SHOT CROSSES IT. A shambler on your own side sits short of // the tape, so the ball never reaches that plane — but `tN` is still positive (the net is ahead // in the direction of travel, the ball simply lands first), the height test there is far below // the tape, and the loop cut pace all twelve times. Measured: every same-side shot left the // racket at 28% of nominal, as a slow loop, which is exactly the shot you need to be fast — // those are the ones about to eat you. const crossesNet = (z0 - COURT.netZ) * (tz - COURT.netZ) < 0; for (let i = 0; crossesNet && i < NET_LOFT_TRIES; i++) { const tN = (COURT.netZ - z0) / v.vz; const yN = y0 + v.vy * tN - 0.5 * g * tN * tN; // The sim only samples the net plane once a frame, so a steeply DIVING ball can test below // the tape even when the closed form clears it. Charge margin for exactly that sampling // error (descent rate × the worst frame step) rather than a flat quality cushion — that // cushion is paid for in pace, and it was taxing precisely the shots meant to hurt. const margin = 0.06 + Math.abs(v.vy - g * tN) * 0.04; // 0.04 s = main.js's dt clamp if (tN <= 0 || yN >= COURT.netH + margin) break; s *= 0.9; v = solveShot(x0, y0, z0, tx, tz, s, g, ty); } return { v, g }; } // What render draws while you pull: the whole shot, error-free, from the same two functions the // release will use. null when there is nothing to show yet. export function previewServe(aim) { if (!aim || aim.mag < TUNE.pullMin) return null; const q = pullQuality(aim.mag); const c = dragContact(); const s = TUNE.serveSpeed + q * TUNE.serveQualitySpeed; const st = splatServeTarget(P, aim, s, c.x, c.z); if (!st) return null; const { v, g } = serveArc(c.x, c.y, c.z, st.tx, st.tz, q, st.ty); return shotBand(c.x, c.y, c.z, v, g, st.tx, st.tz, st.ty, !!st.ft.zt, q); } // The drawable form of a shot: everything render needs and nothing that would pin a zombie object // alive (`zt` is a flag, not the body — the trail below outlives whatever it was aimed at). `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) { 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, f: qualityFrac(q), T, t: 0 }; } // `perfect` is passed SEPARATELY from `quality` because the two serves earn it differently: the toss // ritual earns it on a 45 ms timing window, while the slingshot's quality saturates at pullFull and // so can never fail the old `quality > 0.999` test once you have pulled far enough. function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { const toFar = sv.side === 'P'; const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; // splatServeTarget + serveArc are shared with previewServe, so the arc drawn under your thumb // is the one the ball flies. Only the aim error is added here and left out of the preview. const st = splatServeTarget(sv, aim, s, ball.x, ball.z, (Math.random() * 2 - 1) * (1 - quality) * 0.8); let boxX, tzs; if (st) { boxX = st.tx; tzs = st.tz; } else { // target: diagonal service box. Serving from deuce side → target box is across. boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality); const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6; tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5; } const { v, g } = serveArc(ball.x, ball.y, ball.z, boxX, tzs, quality, st ? st.ty : 0.08); // THE BALL ERASES ITS OWN AIM. Cutting the band at the instant of release made a fast rally // strobe — band, gone, band, gone. Instead the arc stays and the ball eats it from behind as it // flies, so the aim resolves into the shot rather than blinking out of existence. This is the // ACTUAL launch, error included, so the ball tracks the band it is consuming exactly. if (st) G.trail = shotBand(ball.x, ball.y, ball.z, v, g, boxX, tzs, st.ty, !!st.ft.zt, quality); ball.g = g; ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz; ball.lastHit = sv.side; ball.bounces = 0; ball.pow = quality; ball.kills = 0; // fresh serve: multi-kill streak restarts ball.praise = null; // ...and any word a spent ball never got to pop dies with it // the SELECTED tin arms the serve; slot 0 (PLAIN) throws a normal ball and banks your // specials for later. Placement still follows the flick — only WHICH ball flies is the // rack's call now. GUN (slot 4) never reaches here; it starts a burst before the serve. // ...and the DUEL is plain balls, full stop. startDuel forces slot 0, but slot 0 routes through // G.temp — so with a temp weapon riding it every duel serve armed and spent the temp (a laser // fired instead of the ball and aborted the point). Tennis rules means 'ball', temp or no temp. const wsel = G.mode === 'splatter' && !G.interlude ? G.weapon : 0; const wid = G.interlude || (wsel === 0 && !G.temp) ? 'ball' : slotId(wsel), armed = slotStock(wsel) > 0; ball.wid = armed ? wid : 'ball'; // an unstocked tin throws a plain ball — file the kills that way // THE LASER fires instead of the ball, down the lane this serve was aimed at. Everything else // about the gesture is untouched — the pull, the aim, the release — which is the whole point of it. if (wid === 'laser' && armed) { spendSlot(wsel); fireBeam(ball.vx, ball.vz); ball.active = false; G.phase = 'over'; G.overT = 0.5; sv.pose = 'serve'; sv.poseT = 0; return; } ball.bomb = wid === 'bomb' && armed; if (ball.bomb) spendSlot(wsel); ball.flame = wid === 'fire' && armed; if (ball.flame) spendSlot(wsel); if (wid === 'triple' && armed) { // one serve, three balls: clones fanned ±6° spendSlot(wsel); for (const deg of [-6, 6]) { const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r); // g: ball.g — a serve carries up to 2.3x gravity in topspin; clones integrated at flat // TUNE.gravity floated long while the centre ball dived, which is the "wings lob off at // the wrong height" report. They are the SAME shot, fanned. extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, g: ball.g, wid: 'triple', vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c }); } } // (spendSlot already hands you back plain balls when the last one goes) // tennis only, same reason as doHit's: a splatter serve lands at a shambler's feet, and the ring // sat there in yellow for 1.3 s saying nothing — right where the aim mark is trying to be read. if (sv.side === 'P' && G.mode === 'tennis') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 }; G.phase = 'rally'; sfx(sv.side === 'P' ? 'tennis-player' : 'tennis-opponent', 0.9, { reverb: true }); // recorded serve strike, per side sv.pose = 'serve'; sv.poseT = 0; if (sv.side === 'P') G.tossDrops = 0; // a served toss ends the apex lesson if (perfect && sv.side === 'P') { G.hitstop = 0.05; // the word is ARMED here and pops at the IMPACT — first body the ball touches, or its first // bounce. Praising at release meant the HUD spoke while the ball was still mid-air. ball.praise = praiseWord(); } } // ---------------------------------------------------------------- scoring ---- function scoreKey() { const N = ['love', 'fifteen', 'thirty', 'forty']; const s = score.server === 'P' ? [score.pts[0], score.pts[1]] : [score.pts[1], score.pts[0]]; if (s[0] >= 3 && s[1] >= 3) { if (s[0] === s[1]) return 'deuce'; return (s[0] > s[1]) === (score.server === 'P') ? 'advantage_player' : 'advantage_receiver'; } if (s[0] === s[1]) return N[s[0]] + '_all'; return N[s[0]] + '_' + N[s[1]]; } // ---- the modal: the only thing in the game that stops the world. Play resumes on a tap, not // on a timer, so a popup can be READ. It only ever touches G — never the zombie array it may be // called from mid-iteration. // A second popup opened in the same beat QUEUES rather than being dropped. It used to be dropped // ("if (G.modal) return"), which is why the trainer and the weapon had to be crammed into one card // after a won duel — the second one simply vanished. They are two things, so they are two popups. export function showModal(m) { m.in = 0; // 0..1 entrance clock, ticked in update() behind the freeze if (G.modal) { G.modalQ.push(m); return; } G.modal = m; whoosh(); } export function dismissModal() { const m = G.modal; if (!m) return false; // Still flying in? Eat the gesture but DON'T dismiss. A modal you haven't read yet must not be // skippable by the same panic tap that was mid-air when it appeared — and returning false here // would let that tap fall through into a serve, which is the swallowed-gesture bug all over again. if (m.in < 1) return true; G.modal = G.modalQ.shift() || null; if (G.modal) { G.modal.in = 0; whoosh(); } // the next one flies in on its own entrance if (m.onDismiss) m.onDismiss(); return true; // the caller must swallow the gesture: dismissing must never also serve } // The umpire's asides. He has never once acknowledged that the match turned into a massacre, and // that is the joke — so he keeps officiating it. But each line is tied to a SITUATION that actually // justifies it, not drawn from a bag on a timer: Oliver's verdict on the first version was "too much // and not situation aware.. they seem random". `aside()` is the single entry point and it enforces // one shared cooldown (TUNE.asideEvery), so several triggers firing at once still yields one remark. export function aside(name, delay = 0.2) { if (G.mode !== 'splatter' || G.modal || G.changeover || G.interlude || G.card) return; if (G.asideT > 0) return; G.asideT = TUNE.asideEvery; voice(name, delay, 0.95); } export function cheer(seconds, vol) { sfx('tennis-applaus', Math.min(1.5, vol * 2.2)); // recorded crowd; vol scales with the size of the moment (gain > 1 amplifies) // floored at the CLIP LENGTH: the sample always plays out in full, so the stand must stay on its // feet until it stops. `seconds` still lets a big moment run longer than the audio. crowd.cheer = Math.max(crowd.cheer, seconds, APPLAUSE_T); } function advancePoint(side) { // shared scoring math; returns whether a game was won const i = side === 'P' ? 0 : 1, j = 1 - i; score.pts[i]++; if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) { score.games[i]++; score.pts = [0, 0]; return true; } return false; } function pointTo(side, reason) { if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it const n = G.rallyNear; logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`, dMin: r2(n.d), y: r2(n.y), ballZ: r2(n.bz), px: r2(n.px), pz: r2(n.pz) }); G.passStreak++; // two of these in a row and the ghost hand shows the tap (render.drawHitHint) } const gameOver = advancePoint(side); G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2; sendBoy(); if (side === 'O') aww(0.8); // you dropped the point — the crowd sighs if (reason) voice(reason); if (gameOver) { if (G.mode === 'tennis' && side === 'P' && !G.everTransitioned) return startTransition(); voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7); cheer(2.8, 0.6); score.server = other(score.server); } else { voice(scoreKey(), 0.7); if (side === 'P') cheer(1.2, 0.3); } G.serveNum = 1; } // splatter-mode scoring: points land mid-flight without interrupting play. // quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point. // The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde, // odd games end in a changeover breather. // Which bodies gate the hand-clear after a won game. The racket zombie keeps its residence, and the // ball machine is furniture — a standing threat you deal with WHEN YOU CHOOSE. Making either block // progression would turn "decide what to prioritise" into "clear the chore list". // A barrel must NOT gate the hand-clear: it is optional loot, and making it block progression would // turn "decide whether that is worth a shot" into a chore every time one is on court. const blocksClear = z => !'rmo'.includes(z.variant); export function splatPoint(side, quiet = false) { if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter if (side === 'P') G.killCount = 0; const gameOver = advancePoint(side); if (gameOver) { G.runGames++; // the schedule calls a DUEL, not a tin: it comes round on its own so there's always a chance // at a weapon, but you still have to win the point to be handed one. Fires at the next clear. if (G.runGames === TUNE.duelFirst || (G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0)) G.duelDue = true; // ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from // proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks // everything else there — a boss must not walk on into a standing wave. if (G.runGames % TUNE.bossEvery === 0) G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a // long stretch with no clear skips a chapter rather than queueing one, which is intended voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak if (side === 'O') aside('ump_scoreline', 1.4); // they took a game: he would like that noted if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan const i = side === 'P' ? 0 : 1; if (score.games[i] >= 6) { G.sets[i]++; score.games = [0, 0]; G.afterClear = 'set'; } // set banked; a horde follows the card else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games else G.afterClear = 'game'; // even game: a beat, then the next wave G.clearWinner = side; // The racket zombie is DEMOTED: he used to appear on every single game you won, which is exactly // why he read as "the boss" and why a real boss would not have stood out next to him. He is an // occasional opponent now — every racketEvery games — and the chapter boss is the wall. if (side === 'P' && G.runGames % TUNE.racketEvery === 0 && !zombies.some(z => z.variant === 'r') && !G.bossFight) spawnRacketZombie(); // the rogue ball machine gets wheeled out on the same kind of schedule, capped on court if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0 && zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight) spawnMachine(); // ...and a weapon barrel on its own schedule — which TIGHTENS as the run goes on (barrelRamp), // because the late game is where the rack has to carry you. Still one at a time: two glowing // pink things on court would out-shout everything the palette uses to say "this one is dangerous". if (side === 'P' && G.runGames % Math.max(1, TUNE.barrelEvery - Math.floor(G.runGames / TUNE.barrelRamp)) === 0 && !zombies.some(z => z.variant === 'o') && !G.bossFight) spawnBarrel(); // The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before // play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear() // fires (see update()). If the winning shot already emptied the court, proceed at once. if (zombies.some(blocksClear)) { G.clearing = true; ball.active = false; G.flash = { text: 'CLEAR THE COURT', t: 1.8 }; G.phase = 'over'; G.overT = TUNE.gameBeatT; // a short beat to register the game, then the serve loop resumes for the mop-up } else proceedAfterClear(); } else if (!quiet || G.idleT - G.lastScoreT > 2) { voice(scoreKey(), 0.45); G.lastScoreT = G.idleT; if (side === 'P' && !quiet) cheer(0.7, 0.25); } } // the court is mopped up (or was already clear): play advances to whatever the won game earned — // a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde. export function proceedAfterClear() { G.clearing = false; const mode = G.afterClear, side = G.clearWinner; G.afterClear = null; // A clear is the ONE moment the court is guaranteed empty — the natural slot for the scheduled // duel. (A wall-clock timer would need its own "wait for a safe moment" and could fire mid-swarm.) // The chapter boss outranks everything here, including a set: a boss walking on into a standing // wave (or into a horde) would be unreadable. Like the duel it parks the interrupted beat. if (G.bossDue) { const c = G.bossDue; G.bossDue = 0; G.afterDuel = mode || 'game'; startBossFight(c); return; } // The set/horde beat outranks the duel; the duel just waits for the next clear. if (mode !== 'set' && G.duelDue) { G.duelDue = false; G.afterDuel = mode || 'game'; startDuel(); return; } if (mode === 'set') { G.card = { title: 'SET ' + (G.sets[0] + G.sets[1]), // the set just banked (sets already ++'d at game over) sub1: side === 'P' ? 'THE SET IS YOURS' : 'SET TO THEM', sub2: 'SETS ' + G.sets[0] + ' – ' + G.sets[1], color: side === 'P' ? UI.pick : UI.hot, t: TUNE.setCardT }; G.pendingHorde = true; // the horde begins when the card's pause ends ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT; } else if (mode === 'changeover') { startChangeover(); } else { // 'game': even-game beat, then the next wave arms ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT; } // (no 'GAME N' flash on either path anymore — the bar under the scoreboard prints the game // number permanently, and the flash was the same news shouted a second time) } // changeover (after odd games, as the rules demand): a forced breather — no spawns, // no serving, the boys mop. Zombies already on court keep walking; it's calm, not safe. function startChangeover() { G.changeover = TUNE.changeoverT; // seconds left, not a flag: "Time." is called when THIS runs out G.phase = 'over'; G.overT = TUNE.changeoverT; // out with the mop, across the near court where the gore is — NOT to the ball: a dead ball // parked off the field (sendBoy's z guard) meant no boy came out at all, and the breather is // now long enough that an empty court reads as the game having stalled. sendBoy(0, COURT.L * 0.75, true); // near enough the camera to actually watch him work // no CHANGEOVER flash — the countdown panel says 'CHANGEOVER · RESUMING IN n' for the whole // breather (render), so the flash was the same word with a shorter lifespan aside('ump_still_a_match', 2.6); // the breather is when he reminds you this is a match // no tins here anymore — weapons are won in the duel, never handed out on a schedule } // ---- the courtside rack. RACK_MAX + 1 slots, and only slot 0 is fixed: it is BALL (infinite), or // an overflow weapon riding it temporarily. Slots 1..RACK_MAX are what you have picked up, in order. // EVERYTHING READS THROUGH THESE FOUR, so nothing else has to know how the rack is stored — and // nothing else may hardcode how MANY slots there are. `slotOf` exists because endBurst did exactly // that ("[0,1,2,3,4].find(...)"), so the moment the rack grew past five, a burst weapon sitting in // slot 5..9 could never be found and never spent: beacon, flamer and rocket sat at their pickup // count forever. One accessor, one place that knows the range. const slotEntry = i => (i === 0 ? G.temp : G.rack[i - 1]) || null; export const slotId = i => slotEntry(i)?.id || (i === 0 ? 'ball' : null); export const slotStock = i => { const e = slotEntry(i); return e ? e.n : (i === 0 ? Infinity : 0); }; const slotOf = id => { // -1 if you are not carrying it for (let i = 0; i <= RACK_MAX; i++) if (slotId(i) === id) return i; return -1; }; export function selectWeapon(i) { if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only // A HORDE is the one place the rack stays locked: your finger is steering the man, the racket // fires itself, and there is no gesture left to spend on a tin. Everything else can be switched // out of, including mid-burst — see below. if (G.horde) return; if (slotStock(i) <= 0) return; // dud: nothing in that slot (slot 0 is only ever empty if a spent // A BURST IS NOT A COMMITMENT. Committing to a burst tin used to lock the rack for its whole // window — eleven seconds of beacon lobs while a late-game crowd walks into you is a death you // can watch coming and do nothing about, which is exactly what Oliver hit. Tapping another slot // ABORTS it: the tin is spent (you did fire it) and the serve loop comes straight back, the same // handover updateZombies does when the window ends on its own. Both input paths land here — the // rack tap and the 1-0 keys — so the abort lives here rather than in either of them. if (G.mgT > 0) { // ...but spending a tin can EMPTY it, and spendSlot splices an empty entry out, which shifts // every slot above it down one. A tap is at a slot; what you meant was the weapon you could see // in it. So resolve to the id first and look it up again afterwards — otherwise aborting a // beacon in slot 1 to reach the rocket in slot 4 hands you whatever slid into slot 4. const want = slotId(i); G.mgT = 0; endBurst(); setupPoint(); const j = slotOf(want); i = j >= 0 ? j : 0; // the tin we just spent WAS the one tapped: fall back to BALL } G.weapon = i; // temp has just been cleared, and then it is BALL again) sfx('weapon-click2.mp3', 0.45); // a real mechanical click, quiet — switching is audible, not loud } // Grant a weapon and equip it. Tops up what you already carry, else takes a free slot, else rides // slot 0 as `temp` — Oliver's rule: a fifth is always allowed, because a barrel arms on the spot and // is spent quickly, so refusing it would mean a barrel you shot did nothing. export function grantWeapon(id, n = WEAPONS[id].tin) { const have = G.rack.find(e => e.id === id); if (have) { have.n = Math.min(TUNE.wCap, have.n + n); G.weapon = G.rack.indexOf(have) + 1; } else if (G.rack.length < RACK_MAX) { G.rack.push({ id, n }); G.weapon = G.rack.length; } else if (G.temp?.id === id) { G.temp.n = Math.min(TUNE.wCap, G.temp.n + n); G.weapon = 0; } else { G.temp = { id, n }; G.weapon = 0; } return G.weapon; } // Spend one from a slot, and hand you back plain balls when it runs dry. The temp weapon does not // leave an empty dimmed slot behind the way a rack entry does — slot 0 simply becomes BALL again. function spendSlot(i) { const e = slotEntry(i); if (!e) return; if (--e.n > 0) return; // Spent, so it LEAVES — a slot is for what you are carrying, and an exhausted weapon sitting in // one is a quarter of the rack you can never pick anything up into. (The old fixed rack kept its // empty slots dimmed, which was right when the slot meant "BOMB goes here" and is wrong now.) if (i === 0) G.temp = null; // slot 0 goes back to being the infinite ball else G.rack.splice(i - 1, 1); G.weapon = 0; } // the weapon ladder: each delivery opens the next tin in the cycle and ADDS it to the rack // (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde // instead of spending it now. G.weapon picks which tin the next serve throws. const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out export function deliverTin() { // WALK the ladder to the next rung you have room for, rather than cycling blindly onto one that // is already at wCap. Blind cycling meant `grantWeapon` clamped the top-up to a no-op and a won // duel silently handed you nothing — and past about the thirteenth duel three rungs of four are // full, so most late-run duels paid out in literally nothing. Oliver: "later in the game a won // duel does not give you weapons anymore." const stock = id => { const s = slotOf(id); return s < 0 ? 0 : slotStock(s); }; // rack OR the temp slot let id = null; for (let k = 0; k < TINS.length; k++) { const cand = TINS[(G.deliveries + k) % TINS.length]; if (stock(cand) < TUNE.wCap) { id = cand; G.deliveries += k + 1; break; } } // ...and when the whole four-rung ladder is capped, PAY OUT OF THE BARREL RACK instead. Past // about game 20 all four tins sit at wCap and the duel — the one thing in the run you play for — // handed you nothing at all: "the duel does not always give you new weapons". The barrel weapons // are the natural next rung, they already exist, and you cannot otherwise win one without // shooting a barrel. Unlocked ones only, so this can never hand you a rocket in game three. if (!id) { const late = Object.keys(BARREL_WEAPONS) .filter(w => G.runGames >= BARREL_WEAPONS[w].from && stock(w) < TUNE.wCap); if (late.length) id = late[G.deliveries++ % late.length]; } // Every tin AND every barrel weapon full is a real state and not a bug, so say so plainly instead // of miming a handover that cannot happen. No boy, no umpire line — nothing was delivered. if (!id) { G.flash = { text: 'RACK FULL', t: 1.1 }; return; } const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking) grantWeapon(id); if (wasPlain) G.weapon = 0; sendBoy(); voice('new_balls', 0.6); aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently." // THE PRIZE GETS ITS OWN CARD: the tin's picture, its name, and how many. Nothing else — the // first-sight MODAL is gone (a popup that freezes a live court mid-run costs runs) and so is the // prose that replaced it. You have just won a point; you want to know what landed and how much. G.card = { title: WEAPONS[id].label, sub1: '×' + WEAPONS[id].tin, icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT }; } // ---- TEST MODE. Triple-tap the scoreboard. A sandbox for trying the new content without playing // twenty games to reach it: a steady medium wave and every barrel 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: 'TEST MODE', color: UI.pink, body: ['A STEADY CROWD, MEDIUM SPEED', 'EVERY BARREL WEAPON, KEPT FULL', '', 'TRIPLE-TAP THE SCOREBOARD AGAIN TO STOP'] }); return true; } // every barrel weapon on the rack at full stock. 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(BARREL_WEAPONS)) { const have = G.rack.find(e => e.id === id); if (have) have.n = Math.max(have.n, WEAPONS[id].tin); else if (G.rack.length < RACK_MAX) G.rack.push({ id, n: WEAPONS[id].tin }); } } function tickTestMode(dt) { if (!G.testMode) return; stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and } // one rolling across the sandbox was just something else to look at // ---- the interlude: order is restored, one sudden-death point vs the racket zombie. // Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching. // This is now the ONLY source of weapons in the game: win the point or leave empty-handed. export function startDuel() { if (G.interlude) return; if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel G.weapon = 0; // tennis rules for the duel: plain balls, no tins G.mgT = 0; endBurst(); pellets.length = 0; // ...and a burst in progress is over. The umpire has restored order. G.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces G.boy = null; // ...and the mop boy is waved off the court (sendBoy is barred for the duration) ball.active = false; setupPoint(); zombieDuck(true); // fade the zombie music out for the 1:1 duel voice('play_resume', 0.2); voice('sudden_death', 2.4); if (!G.seen.duel) { // the rules change back — worth one real interruption, once per run G.seen.duel = true; showModal({ title: 'SUDDEN DEATH', color: UI.hot, body: ["IT'S TENNIS AGAIN — ONE POINT", 'WIN IT AND THEY HAND YOU A WEAPON'] }); } else G.flash = { text: 'SUDDEN DEATH', t: 1.6 }; } // ---- the chapter boss: every TUNE.bossEvery games, the court is cleared and ONE thing walks on. // No other spawns until it is down, so the fight is legible and so banking a bomb for it is a real // decision (which is the point — tins arrive every 3 games, so there was never a reason to save one). // It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary // instant death. G.bossFight holds the chapter number while it runs. export function startBossFight(chapter) { if (G.bossFight) return; G.bossFight = chapter; zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage G.horde = null; G.mgT = 0; endBurst(); G.clearing = false; ball.active = false; setupPoint(); spawnChapterBoss(chapter); zombieDuck(true); // the music drops out for it voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85); const art = BOSS_ART[(chapter - 1) % BOSS_ART.length]; G.card = { title: 'CHAPTER ' + chapter, sub1: art.name, sub2: 'IT THROWS. RETURN IT.', color: UI.bad, t: TUNE.setCardT }; G.flash = null; if (!G.seen.boss) { // the rules change: a wall, and a thing that shoots back G.seen.boss = true; showModal({ title: 'IT BRINGS A MACHINE', color: UI.bad, body: ['IT THROWS BALLS AT YOU', 'SWING TO RETURN THEM — OR SWISH TO BAT THEM AWAY', 'SWIPE SIDEWAYS TO STEP OUT OF THE WAY', 'A HIT COSTS BLOOD. WIN A DUEL TO HEAL.'] }); } } // the chapter boss is down: the parked game beat resumes and normal spawning comes back export function endBossFight() { if (!G.bossFight) return; const c = G.bossFight; G.bossFight = 0; zombieDuck(false); shots.length = 0; cheer(3.4, 0.9); voice('unbelievable', 0.3); // hand back the game beat the boss interrupted FIRST — same contract resolveInterlude uses. It // used to run LAST, and its 'set' branch writes a routine SET card and a short overT: on a game // that is both a set and a boss chapter (game 30), the EPIC card below was overwritten before it // had drawn a single frame, and the beat was cut to setCardT. The boss card wins now. if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); } // The longest fight in the game deserves more than the two seconds a routine set gets. Big word, // long hold — the chapter and the crowd move down to the subs where they belong. // (no 'GAME n NEXT' line: the bar under the scoreboard prints the game number permanently, and a // card that repeats what the HUD already says is the clutter this pass is meant to remove.) G.card = { title: 'EPIC!', sub1: 'CHAPTER ' + c + ' DOWN', sub2: 'THE STANDS ARE ON THEIR FEET', color: UI.pick, t: TUNE.bossWinT, wait: TUNE.bossDeathT }; // ...after the body has finished coming apart ball.active = false; // a duel the handback just started owns the phase; otherwise hold the boss beat, and never // SHORTEN a longer one the handback set (a changeover's 5 s outlasts this) if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); } } // ---- THE DEADLOCK WATCHDOG. // A run that hard-locks costs the player everything, and this file has form: `mgT` was once left // above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the // game sat there with the gun racket on screen. This is the net. // // It cannot misfire on a legitimate wait, and that is the whole design: every legitimate reason you // cannot serve — a horde, a burst, a changeover, the duel's announcement, an 'over' beat — has a // CLOCK counting down toward letting you act. So the test is not "how long has the player been // blocked" (a horde blocks you for fifteen honest seconds); it is "is the thing that should be // releasing me actually moving". If none of those numbers changes for stuckT seconds while the // player is blocked and no popup is waiting on them, the state is dead and nothing will revive it. // // It also LOGS what was frozen, into the same miss log the scoreboard tap already shows — a lock // that unsticks itself and leaves no evidence is worse than one that does not. let stuckT = 0, stuckSig = ''; function watchdog(dt) { if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate const blocked = G.horde || G.mgT > 0 || G.changeover || G.phase === 'over' || (G.interlude && G.interlude.introT > 0) || (G.phase === 'toss' && ball.y < 2.0); // a toss on the floor: only a held aim keeps it there if (!blocked) { stuckT = 0; return; } const sig = [G.horde && G.horde.t, G.mgT, G.changeover, G.interlude && G.interlude.introT, G.phase === 'over' && G.overT, ball.y].map(v => (+v || 0).toFixed(2)).join('|'); if (sig !== stuckSig) { stuckSig = sig; stuckT = 0; return; } // something is still moving — fine if ((stuckT += dt) < TUNE.stuckT) return; logSwing({ result: 'UNSTUCK', reason: `nothing moved for ${TUNE.stuckT}s — released a dead latch`, phase: G.phase, sig, aim: !!G.aim }); G.horde = null; G.mgT = 0; G.changeover = false; G.interlude = false; G.aim = null; endBurst(); setupPoint(); stuckT = 0; stuckSig = ''; } export function resolveInterlude(side) { if (!G.interlude) return; G.interlude = false; zombieDuck(false); // duel over — bring the music back up ball.active = false; G.phase = 'over'; G.overT = 2.2; if (side === 'P') { cheer(3, 0.7); deliverTin(); // win the point, win the next tin // ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is // the only healing in the game, and blood is the only thing you cannot buy back otherwise. It // gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line // of body text under a weapon icon it read as a footnote to the tin rather than as the prize. if (G.hp < G.hpMax) { G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel); voice('ump_trainer', 0.2, 0.95); showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick, body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] }); } } else { voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel score.games[1]++; G.runGames++; // it goes on the scoreboard if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; } G.flash = { text: 'GAME. THEM.', t: 1.6 }; for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b'); } if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); } } // retire the splatter ball; a running changeover's long beat must not be clobbered short export function spentBall() { ball.active = false; G.phase = 'over'; if (!G.changeover) G.overT = TUNE.splatterOverT; } // ---- THE SIDESTEP. Free walking is 'rally'-only (see the movement block) and that is staying — // but it left you welded to the spot through a whole boss fight, because a boss is fought from the // serve stance and the one finger is already spent on the slingshot. So: a horizontal swipe is a // committed step, not a joystick. It integrates OUTSIDE the movement block so it works in every // phase, and it carries invulnerability, which is the only reason to make one. export function dodge(dir) { if (G.mode !== 'splatter' || G.modal || G.dodgeCd > 0 || G.dodgeT > 0) return; G.dodgeDir = dir; G.dodgeT = TUNE.dodgeT; G.dodgeCd = TUNE.dodgeCool; G.dodgeI = TUNE.dodgeIFrame; dustPuff(P.x, P.z, 6); // the committed step kicks up the court — it also marks where the i-frame began G.idleHintT = 0; // ...and the apron demo goes away, like any other gesture. The touch path nulls // G.aim to fire this, and the demo's own guard only watches G.aim. P.facing = dir; // ...but a swing in flight keeps its pose. You can step out of the way mid-shot and the shot still // lands (swingT is untouched); clobbering the pose only threw away the animation for it. if (P.pose !== 'swing' && P.pose !== 'serve') { P.pose = 'run'; P.poseT = 0; } swish(0.22); } // How far out he may stand. P_BOUNDS.x is 6.5 m, but at the baseline in portrait the camera only // shows about ±4 m either side of the centre line, so walking to the bound put him clean off the // edge. Floored at the sideline so every legal tennis ball stays catchable — the court is never out // of bounds. Shared by the movement block and the dodge; both were inline in the former. const xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); const xLimit = () => Math.max(xVisible(), COURT.W / 2); // the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you function startHorde() { if (G.horde) return; G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse // the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell // further behind on every set banked (the second horde was where every run ended) G.horde = { t: TUNE.hordeLen * grow, spawnT: 0, mgT: 0, rate: TUNE.hordeRate * grow, mgRate: TUNE.mgRate * grow }; ball.active = false; G.phase = 'rally'; // ball gone; 'rally' lets the movement block steer P P.pose = 'idle'; P.poseT = 0; P.swingT = -1; // clear the triggering shot's serve/swing pose P.z = HOME.P.z; // brace at the baseline — the horde is a hold-the-line stand (finger steers x only) aside('ump_spectators', 0.4); // a horde IS the crowd on the court. (The near-identical 'remain seated' // that used to play right before it belongs to the transition, not here.) G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; } // hidden shortcut: tapping the umpire three times summons the horror early // Back to a fresh run WITHOUT reloading the page. The reload was the honest reset, but it also tore // down the AudioContext, so the menu theme restarted from silence every time you died — and the menu // is supposed to pick up the track that was already playing over the death summary. Every mutable // bag has a pristine copy (state.resetState) and the two modules with timers of their own put them // back themselves, so nothing here has to know the shape of any of it. export function restartRun() { resetZombies(); resetInput(); resetState(); setupPoint(); } export function forceZombies() { if (G.mode === 'splatter') { startHorde(); return; } // in splatter the cheat forces a horde wave if (G.mode !== 'tennis') return; ball.active = false; startTransition(); } // -------------------------------------------------------------- transition ---- // You won your first game. The applause dies. The match... continues. function startTransition() { G.mode = 'transition'; G.transT = 0; G.tFlags = {}; G.everTransitioned = true; G.overT = 9999; // hold the point flow; the timeline below releases it applause(0.7, 0.5); // the clap that gets cut off G.serveNum = 1; } function transition(dt) { const t = (G.transT += dt); const once = (key, at, fn) => { if (t >= at && !G.tFlags[key]) { G.tFlags[key] = true; fn(); } }; once('quiet', 1.8, () => voice('quiet_dark', 0, 0.82)); // THE CAMERA HAS TO BE THERE BEFORE IT HAPPENS. The zoom used to start ON the turn — and the // curve render applies is `sin(p*PI)`, which is ZERO at p=0, so the punch-in only reached full // magnification half a second AFTER he had already changed. You watched the aftermath every time. // It starts at 1.8 with T 3.2 so the peak (p=0.5) lands exactly on the turn at 3.4, holds through // it, and pulls back out as the mob walks on — which becomes the reveal of what else is coming. once('closein', 1.8, () => { G.zoom = { x: O.x, z: O.z, k: 2.1, t: 0, T: 3.2 }; }); // ...and the turn itself is a sprite SWAP, one frame to the next, with nothing to sell it. So it // lands on a beat: the world stops dead for a moment (hitstop), the screen flashes, he comes // apart and puts himself back together wrong. The freeze is what makes a swap read as an EVENT // rather than as a glitch — the eye is given something to blame the change on. once('turn', 3.4, () => { G.hitstop = 0.16; // the world holds its breath G.boomFlash = 0.16; G.shake = { t: 0.7, mag: 7 }; shriek(); splat(0.9); boom(); for (let k = 0; k < 40; k++) parts.push({ // he bursts, and what stands up is not him x: O.x, y: 0.5 + Math.random() * 1.5, z: O.z, vx: (Math.random() * 2 - 1) * 3.5, vy: 1 + Math.random() * 4, vz: (Math.random() * 2 - 1) * 3.5, t: 0.5 + Math.random() * 0.7, dark: Math.random() < 0.45 }); gore.push({ x: O.x, z: O.z, r: 1.1, a: 0.9 }); spawnZombie(O.x, O.z, 'c'); G.hideO = true; // the opponent... turns }); once('mob', 4.8, () => { spawnZombie(-3.5, -1.6, 'a'); spawnZombie(2.5, -2.1, 'b'); G.shake = { t: 0.5, mag: 6 }; }); once('seated', 5.4, () => voice('remain_seated', 0, 0.95)); once('go', 8.2, () => { G.mode = 'splatter'; score.server = 'P'; setupPoint(); voice('play', 0.4, 0.9); zombieMusic(); G.shake = { t: 0.7, mag: 9 }; // the floor goes out from under the match // NO modal here anymore — nobody read it (Oliver's testteam, verbatim). The ghost hand teaches // the pull instead: prime the idle clock so the apron demo starts miming IMMEDIATELY rather // than after the usual beat of inaction, and it keeps coming back whenever the player stalls. G.idleHintT = TUNE.pullHintAfter; G.flash = { text: 'THE MATCH HAS CHANGED', t: 2.0 }; }); } function fault() { if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); } else pointTo(other(score.server), 'double_fault'); } export function inCourt(x, z) { return Math.abs(x) <= COURT.W / 2 + 0.02 && z >= -0.02 && z <= COURT.L + 0.02; } export function inServeBox(x, z) { const toFar = score.server === 'P'; const xOK = deuceSide() ? (toFar ? x <= 0.02 : x >= -0.02) : (toFar ? x >= -0.02 : x <= 0.02); const zOK = toFar ? (z >= COURT.svcFar - 0.02 && z <= COURT.netZ) : (z >= COURT.netZ && z <= COURT.svcNear + 0.02); return xOK && Math.abs(x) <= COURT.W / 2 + 0.02 && zOK; } function onBounce() { ball.bounces++; pock(0.35, 500); // a deferred praise word that met no body lands with the ball — the DRAW was still perfect if (ball.praise) { popText(ball.x, 0.3, ball.z, ball.praise); ball.praise = null; } if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up explode(ball.x, ball.z); ball.bomb = false; spentBall(); return; } if (G.mode === 'splatter' && G.interlude) { // sudden death: the lines are LIVE again // A ball SPENT on a body ('X' — it wounded the racket zombie without killing him) gets no line // call in either direction: it is nobody's shot anymore. Without this case the P-or-not test // below read 'X' as HIS ball — bounce out and you were handed the duel, bounce twice in-court // and you LOST it for hitting him. Same rule as the dead-roller path in update(). if (ball.lastHit === 'X') { spentBall(); return; } if (ball.bounces === 1) { const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; if (!inCourt(ball.x, ball.z) || !okSide) { ball.active = false; resolveInterlude(other(ball.lastHit)); voice('out'); } } else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); } return; } if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns if (ball.bounces === 1) { const wasServe = G.wasServe; G.wasServe = false; if (wasServe) { if (!inServeBox(ball.x, ball.z)) { ball.active = false; fault(); } return; } // must land on the side opposite the hitter const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; if (!inCourt(ball.x, ball.z) || !okSide) { ball.active = false; pointTo(other(ball.lastHit), 'out'); } } else if (ball.bounces === 2) { ball.active = false; pointTo(ball.lastHit, null); } } // -------------------------------------------------------------- movement ---- // Where should a player run to? Simulate the ball forward and take the first point in // their half at hitting height that they can actually REACH IN TIME; otherwise chase the // deepest hittable point. This is the "every ball is catchable, timing is the skill" contract. function intercept(side) { const ch = side === 'O' ? O : P; const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist; let { x, y, z, vx, vy, vz } = ball; const g = ball.g || TUNE.gravity, dt = 0.01; const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH; const home = HOME[side]; let fallback = null, deepest = null; for (let i = 0; i < 300; i++) { vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt; if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; } if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) { const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) }; const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt; if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time if (reachable && !fallback) fallback = pt; // low but arrivable deepest = pt; // last resort: run at the deepest hittable point } } return fallback || deepest || home; } // ---------------------------------------------------------- miss diagnostics ---- // Every player swing in tennis mode gets logged: HIT with quality, or MISS with the // gate that rejected it and the ball's closest approach. Balls that pass without a // swing log as PASSED when the point ends. Inspect live via T.G.missLog; the latest // miss reason is drawn on the HUD (TUNE.missHud). const r2 = x => Math.round(x * 100) / 100; function logSwing(entry) { entry.at = r2(G.idleT); G.missLog.push(entry); if (G.missLog.length > 50) G.missLog.shift(); if (entry.result !== 'HIT') G.lastMiss = { text: entry.reason, at: G.idleT }; } function logWhiff() { const snaps = G.swingSnaps; G.swingSnaps = null; if (G.mode !== 'tennis') return; if (!snaps || !snaps.length) { logSwing({ result: 'MISS', reason: !ball.active ? 'no live ball' : 'ball was yours — already hit' }); return; } const best = snaps.reduce((a, b) => (b.d < a.d ? b : a)); const inReach = snaps.filter(s => s.d <= s.r); let reason; if (!inReach.length) { // never in reach during the swing: was the swing mistimed, or the ball line simply wide? const tX = ball.active && ball.vz > 0.1 ? (P.z - ball.z) / ball.vz : -1; // time until ball reaches your depth const lat = tX > 0 ? Math.abs(ball.x + ball.vx * tX - P.x) : null; // how wide it will pass const nearD = G.rallyNear ? G.rallyNear.d : 1e9; // closest true pass so far this rally if (lat !== null && lat > best.r) reason = `ball line ${r2(lat)}m wide of you — run, don't swing`; else if (tX > 0) reason = `tap too early — ball still ${r2(best.d)}m out, arrives ${Math.round(tX * 1000)}ms after swing ended`; else if (nearD > best.r) reason = `ball passed ${r2(nearD)}m wide of you — never reachable from where you stood`; else reason = `tap too late — ball already gone (it came within ${r2(nearD)}m of you earlier)`; } else { const inWin = inReach.filter(s => s.t >= TUNE.swingWindup && s.t <= TUNE.swingWindup + s.w); if (!inWin.length) { const first = inReach[0], last = inReach[inReach.length - 1]; reason = last.t < TUNE.swingWindup ? `tap too late — ball already passing ${Math.round((TUNE.swingWindup - last.t) * 1000)}ms before swing opened` : `tap too early — ball arrived ${Math.round((first.t - (TUNE.swingWindup + first.w)) * 1000)}ms after window closed`; } else if (inWin.every(s => s.y > CONTACT.maxY)) reason = `too high — ball at ${r2(best.y)}m overhead`; else if (inWin.every(s => s.y < CONTACT.minY)) reason = `too low — ball at ${r2(best.y)}m, past you`; else reason = 'unclassified — in reach, in window, in height band'; // should not happen } logSwing({ result: 'MISS', reason, dMin: r2(best.d), y: r2(best.y), tSwing: r2(best.t), ballZ: r2(best.bz), bounces: best.bounces, px: r2(P.x), pz: r2(P.z) }); } function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { // Contact happens if the ball is inside reach while the swing window is open. // Player volleys (pre-bounce) get wider reach + window: full-speed ball, no bounce cue. const volley = !isAI && ball.bounces === 0; const reach = isAI ? TUNE.aiReach : TUNE.reach * (volley ? VOLLEY.reach : 1); const win = TUNE.swingWindow * (volley ? VOLLEY.window : 1); const t = ch.swingT; if (t < TUNE.swingWindup || t > TUNE.swingWindup + win) return; if (!ball.active || G.phase !== 'rally') return; // splatter: your OWN ball is fair game. It rebounds off the backstop and comes back at you — // volleying it is how you keep firing without walking it down. (The duel is tennis: one touch.) const own = G.mode === 'splatter' && !G.interlude && ch.side === 'P'; if (ball.lastHit === ch.side && !own) return; const dx = ball.x - ch.x, dz = ball.z - ch.z; if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return; if (ball.y < CONTACT.minY && ball.vy <= 0) return; // a DROPPING ball below the ankles is past you — but a rising one is a half-volley, always scoopable // quality: how centered in the window the contact lands const center = TUNE.swingWindup + win / 2; const off = Math.abs(t - center) / (win / 2); let q = Math.max(0.05, 1 - off); if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1; ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite const flick = isAI ? null : takeFlickAim(); const aim = isAI ? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } : flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (qCap - TUNE.aiQualityMin)); if (!isAI && G.mode === 'tennis') { G.swingSnaps = null; G.rallyNear = null; logSwing({ result: 'HIT', reason: `contact q=${Math.round(q * 100) / 100}`, d: Math.round(Math.hypot(dx, dz * 0.8) * 100) / 100, y: Math.round(ball.y * 100) / 100 }); } doHit(ch, q, aim); ch.swingT = -1; } // racket zombie (splatter mini-boss): a slimmed tennis CPU that holds the far court and returns // your SOFT shots as live balls. Splat-quality balls it won't rally — those wound its HP instead. function racketAI(rz, dt) { expirePose(rz, dt); rz.swingCool -= dt; const incoming = ball.active && ball.lastHit === 'P' && ball.vz < 0 && (G.interlude || (ball.pow || 0) < TUNE.zSplatQ); // in the duel it plays EVERY ball — it's tennis now if (!incoming) { if (rz.pose === 'run') rz.pose = 'idle'; return; } const tz = Math.max(1.5, Math.min(COURT.svcFar, ball.z)); // chase laterally, hold deep on the far court const dx = ball.x - rz.x, dz = tz - rz.z, d = Math.hypot(dx, dz); if (d > 0.15) { const step = Math.min(TUNE.racketSpeed * dt, d); rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; if (rz.pose !== 'swing') rz.pose = 'run'; if (Math.abs(dx) > 0.2) rz.facing = dx > 0 ? 1 : -1; } else if (rz.pose === 'run') rz.pose = 'idle'; if (rz.swingCool <= 0 && rz.swingT < 0 && Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach && ball.y < CONTACT.maxY && ball.y > CONTACT.minY) { if (Math.random() < TUNE.racketFlub) rz.swingCool = 0.5; // whiff — it hesitates else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; } } } // A BURST weapon isn't a ball — committing to one braces you at the baseline for a window (the same // mode a horde uses) and it fires ITSELF for `len` seconds while your finger only steers. G.mgT is // the mode timer, which is why the eighteen `!(G.mgT > 0)` guards across five files never changed. // There was briefly a third kind that fired one round per tap; Oliver read it as the weapon being // broken, so a barrel weapon is now either this or a serve. Nothing waits for a tap. function tryArmGun() { if (G.mode !== 'splatter') return false; const id = slotId(G.weapon), w = WEAPONS[id]; if (!w || w.kind !== 'burst' || slotStock(G.weapon) <= 0) return false; G.gunId = id; G.mgT = w.len ?? TUNE.mgPickupLen; // The tin is spent when the burst ENDS, not when it starts. Spending on commit removed the rack // entry immediately, so the countdown bar — which draws on the slot holding G.gunId — had no slot // to draw on and every burst weapon ran with no progress bar at all. Oliver spotted it on the // flamethrower; it was true of all four. G.mgCool = 0; ball.active = false; G.phase = 'rally'; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; P.z = HOME.P.z; // brace at the baseline for the burst (finger steers x only) G.flash = { text: w.label, t: 1.0 }; return true; } export function serveToss() { // A NEW WIND-UP ENDS THE LAST SHOT'S FOLLOW-THROUGH. Without this he latched: hitServe sets // pose 'serve' with poseT 0, that only expires after POSE_TIME, and the pull frame is only drawn // from 'idle' — so firing faster than 0.38 s reset the timer before it could ever expire and he // was welded into p_smash. Racket permanently up, balls appearing out of it, no wind-up at all, // which is precisely the late-game rapid-fire look. Clearing it here restores pull/smash/pull. if (score.server === 'P' && P.pose === 'serve') { P.pose = 'idle'; P.poseT = 0; } if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P); } // The burst is over: NOW the tin is spent. Called from updateZombies when mgT runs out. export function endBurst() { const id = G.gunId; G.gunId = null; if (!id) return; const slot = slotOf(id); if (slot >= 0) spendSlot(slot); } // The laser fired from a TAP (the keyboard/two-stage path). The drag path solves its own direction // and goes through hitServe; this one has no arc to read a lane off, so it takes the held flick aim // if there is one and otherwise fires straight up-court. function tryLaser() { if (G.mode !== 'splatter' || G.interlude) return false; const w = WEAPONS[slotId(G.weapon)]; if (!w || !w.beam || slotStock(G.weapon) <= 0) return false; const flick = takeFlickAim(); const dx = flick ? flick.fx : 0, dz = flick ? -Math.abs(flick.fy) : -1; spendSlot(G.weapon); fireBeam(dx, dz || -1); ball.active = false; G.phase = 'over'; G.overT = 0.5; P.pose = 'serve'; P.poseT = 0; return true; } // release of the slingshot aim: an instant serve to the reticle (quality fixed — placement // is this serve's power; the toss ritual keeps the PERFECT-timing power ceiling) export function dragServe(aim) { if (G.mode !== 'splatter' || score.server !== 'P') return; if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle if (G.phase === 'over') { ball.active = false; setupPoint(); } // mid-beat pull: arm the next shot now if (G.phase !== 'pretoss' && G.phase !== 'toss' && G.phase !== 'rally') return; if (tryArmGun()) return; if (aim.mag < TUNE.pullMin) return; // released without really drawing: cancel, nothing spent // Firing while the last ball still flies is fine again — the brake is the pull itself, not the // rally clock. This is what lets the drag mean ONE thing at all times: a second gesture that // walked the player around between shots was the confusing part, not the fire rate. if (ball.active && G.phase !== 'toss') // the old ball flies on as a ballistic clone extras.push({ x: ball.x, y: ball.y, z: ball.z, vx: ball.vx, vy: ball.vy, vz: ball.vz, pow: ball.pow || 0.5, t: 2.5, g: ball.g, wid: ball.wid }); ball.active = true; ball.collected = false; ball.bounces = 0; ball.x = P.x + 0.12; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; // over his head, see tossBall // a full pull is quality 1.0, but only a pull that STOPS at the top of the draw is praised hitServe(P, pullQuality(aim.mag), aim, pullPerfect(aim.mag)); } // The swish cooldown, scaled by how crowded you are. Oliver died on every game-20 horde: the wave // grows with hordeGrow but the panic button was a flat 1 s, so past a certain density it simply could // not keep up. He picked "melee fires more frequently with horde density" over shrinking the horde. function meleeCooldown() { let near = 0; for (const zb of zombies) if (zb && Math.hypot(zb.x - P.x, zb.z - P.z) < TUNE.meleeCrowdR) near++; return Math.max(TUNE.meleeCoolMin, TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); } export function keyPressed(code) { // Dead men toss no balls. The restart tap lives in main.js (maybeRestart); everything below — // melee, parry, and especially the pretoss branch — must not run while the death animation plays, // or a panic tap conjured a ball that hung frozen midair over the corpse (update early-returns // in eaten mode, so a tossed ball never got a single frame of physics). if (G.mode === 'eaten') return; // Desktop sidestep: an arrow/A/D EDGE outside 'rally', where the walk axes do nothing anyway. // Inside a rally the same keys still walk, so one key never means two things at one moment. if (code === 'ArrowLeft' || code === 'KeyA' || code === 'ArrowRight' || code === 'KeyD') { if (G.phase !== 'rally') dodge(code === 'ArrowLeft' || code === 'KeyA' ? -1 : 1); return; } if (code !== 'Space') return; audio(); // unlock on first gesture // a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the // default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool. if (G.mode === 'splatter' && G.meleeT <= 0) { // measure the crowd BEFORE the swipe — it kills everything in reach, so counting afterwards // counts the SURVIVORS and the cooldown barely moved no matter how swarmed you were const cool = meleeCooldown(); if (meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = cool; } // AN INCOMING SHOT OWNS THE TAP. The parry used to live behind the `phase === 'rally'` branch at the // bottom of this function — and in splatter you are in `pretoss` almost all of the time, so a tap // aimed at a machine's shot tossed the serve ball instead and the return was, in practice, // impossible. Oliver could not land one. A hostile ball in the air is the most visible thing on the // screen, so "the tap is a swing at it" keeps one gesture meaning one thing at any given moment. if (G.mode === 'splatter' && shots.some(s => Math.hypot(s.x - P.x, (s.z - P.z) * 0.8) < TUNE.parryArm)) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; swish(0.3); return; } if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves if (G.changeover) return; // the breather is mandatory — the melee swish above still works if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel // splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); } // splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from // pretoss WITH a ball in hand: one ball, and it has to come back before you fire it again. // A flick is the aimed one-motion variant (the reticle). The duel is exempt: it serves like tennis. if (G.mode === 'splatter' && !G.interlude && score.server === 'P' && G.phase === 'pretoss') { const flick = takeFlickAim(); if (flick) { dragServe(flick); return; } // dragServe handles GUN (burst) and the ball check } if (G.phase === 'pretoss' && score.server === 'P') { if (tryArmGun()) return; // a burst weapon selected: the gesture arms it, no ball // ...and a LASER never tosses one either. The ray IS the weapon, so a two-stage toss ritual was // throwing a ball into the air and then firing something else entirely. if (tryLaser()) return; tossBall(P); return; // plain tap: the two-stage toss ritual (tennis, and the duel) } if (G.phase === 'toss' && score.server === 'P') { // serve timing: distance from apex = quality — a real serve, not a free tap const dy = Math.abs(ball.y - serveApexY()); if (ball.y < 2.0) return; // too low, let it drop (re-toss) let q = Math.max(0.05, 1 - dy / 0.9); if (dy < 0.12) q = 1; G.wasServe = true; // a flick at the apex (or held arrows) makes this a DELIBERATE serve: aimed + consumes a special const flick = G.mode === 'splatter' && !G.interlude ? takeFlickAim() : null; const kb = { ax: axisX(), az: axisZ() }; hitServe(P, q, flick || (kb.ax || kb.az ? kb : null)); return; } if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; } } function expirePose(ch, dt) { if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; } } // ------------------------------------------------------------- ball boys ---- // The fetch boy is unchanged: sprint the width of the court, scoop the dead ball in passing. function fetchRound(b, dt) { const step = b.dir * 7.5 * dt; b.x += step; b.runT += dt; b.dist = (b.dist || 0) + Math.abs(step); if (!ball.collected && !ball.active) { const dz = ball.z - b.z; b.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little if (Math.abs(b.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; } if (Math.abs(b.x) > 13.5) G.boy = null; } // The mop boy actually does a JOB: walk to a stain, stand on it and push the mop until it's // gone, then move to the next one, then leave. He used to slide the width of the court in a // straight line at constant speed, which read as moonwalking past the mess rather than cleaning it. function mopRound(b, dt) { b.runT += dt; if (b.mopT > 0) { // ON a stain: no travel, but the push cycle keeps animating (dist drives frames) b.mopT -= dt; b.dist = (b.dist || 0) + TUNE.boyMopSpeed * 0.6 * dt; // the mop travels even when he doesn't if (b.mopT <= 0) b.jobs--; // that one's done — look for the next return; } // pick the next target: the biggest stain he can still reach, ignoring ones already scrubbed out if (b.tx === undefined && b.jobs > 0) { let best = null, bs = -1; for (const d of gore) { if (d.a < 0.15 || d.z < COURT.netZ - 2) continue; // faded, or up the far end where he can't work const score = d.a * d.r - Math.hypot(d.x - b.x, d.z - b.z) * 0.04; // big and close first if (score > bs) { bs = score; best = d; } } if (best) { b.tx = best.x; b.tz = best.z; } else b.jobs = 0; // nothing left to clean — go home } const working = b.tx !== undefined && b.jobs > 0; const gx = working ? b.tx : b.dir * 14, gz = working ? b.tz : b.z; // no target left: walk off the wing const dx = gx - b.x, dz = gz - b.z, d = Math.hypot(dx, dz); const step = Math.min(TUNE.boyMopSpeed * dt, d); if (d > 0.001) { b.x += dx / d * step; b.z += dz / d * step; b.dist = (b.dist || 0) + step; } if (Math.abs(dx) > 0.05) b.dir = dx > 0 ? 1 : -1; // face the way he's walking if (working && d <= 0.12) { b.mopT = TUNE.boyMopDwell; b.tx = undefined; b.tz = undefined; } // arrived: get to work if (Math.abs(b.x) > 13.5) G.boy = null; // off the wing — round over } // ---------------------------------------------------------------- update ---- export function update(dt) { if (G.hitstop > 0) { G.hitstop -= dt; return; } if (G.mode === 'eaten') { // the match is over in the truest sense G.idleT += dt; const was = G.eatenT; G.eatenT += dt; if (G.eatenT < EATEN_BURST) eatenBleed(dt); // phase A: they are still tearing at you if (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore // phase C: the summary IS the end menu — hand the soundtrack back to the menu theme. Not at // killPlayer: the death itself already has the massacre call and the crowd's groan over it. if (was < EATEN_SUMMARY && G.eatenT >= EATEN_SUMMARY) { zombieDuck(true); menuDuck(false); } crowd.cheer = Math.max(0, crowd.cheer - dt); // the stand sits back down over the death too — // a cheer live at the kill used to clap forever if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // settles rather than freezing if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.boomFlash > 0) G.boomFlash -= dt; for (let i = pops.length - 1; i >= 0; i--) if ((pops[i].t -= dt) <= 0) pops.splice(i, 1); // no DOUBLE KILL frozen over the feast updateZombies(dt); return; } if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.netRipple && (G.netRipple.t -= dt) <= 0) G.netRipple = null; // the tape settles if (G.zoom && (G.zoom.t += dt) >= G.zoom.T) G.zoom = null; // scripted punch-in (render applies it) if (G.bossBurst && (G.bossBurst.t -= dt) <= 0) G.bossBurst = null; if (G.boomFlash > 0) G.boomFlash -= dt; // A modal freezes the WORLD, not the clock: no zombie steps, no ball flight, no spawn timers, // so nobody gets eaten while reading a popup. idleT keeps running for the blink. if (G.modal) { G.idleT += dt; const m = G.modal; // the entrance is the ONE thing that animates behind the freeze if (m.in < 1 && (m.in = Math.min(1, m.in + dt / TUNE.modalIn)) >= 1) pock(0.4, 620); // it lands with a thud return; } // The flash is FROZEN behind a popup too, and it has to be: it used to tick above the return, so // any flash raised in the same beat as a modal spent its whole 1.1 s invisible behind the dim and // was gone before you had tapped. That is exactly what made a won duel look like it paid nothing // late in a run — the weapon modal is first-sight-only, so once you have seen a tin the label // flash IS the announcement, and the trainer's popup was eating it every time you were wounded. if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; } // A card can wait before it shows. The chapter card used to land on the same frame as the boss // died, so the biggest gore burst in the game happened underneath it and nobody ever saw it. // BELOW the modal return, like the flash: a card raised in the same beat as a popup used to // burn its whole life dimmed-out underneath it — now it holds and plays AFTER the dismissal. if (G.card) { if (G.card.wait > 0) G.card.wait -= dt; else { G.card.t -= dt; if (G.card.t <= 0) G.card = null; } } for (let i = pops.length - 1; i >= 0; i--) if ((pops[i].t -= dt) <= 0) pops.splice(i, 1); // reaction pops decay crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; if (G.asideT > 0) G.asideT -= dt; // the asides' shared cooldown (see aside()) if (G.praiseT > 0) G.praiseT -= dt; // ...and the praise words' (see praise()) // the fired band unravels behind the ball. It also goes the moment the ball does — a shambler // eating the shot ends the band with the splat, which is a cut you want. if (G.trail && ((G.trail.t += dt) >= G.trail.T || !ball.active)) G.trail = null; // "Time." belongs to the changeover alone. It used to ride the generic 'over' expiry, so any // other beat that overwrote overT while the breather ran (a melee kill taking a game, say) // had the umpire calling time on a pause that was never a changeover. if (G.changeover > 0 && (G.changeover -= dt) <= 0) { G.changeover = false; voice('time', 0, 0.95); } const w = BOUNCE_IDLE.tempo; // idle in the serve stance and not touching anything: after a beat, show the pull gesture if (G.phase === 'pretoss' && !G.aim) G.idleHintT += dt; else G.idleHintT = 0; if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce // the toss/smash pose belongs to the SHOT, not to the aim: while the slingshot is drawn (G.aim) // the server stays in the pretoss stance and only snaps into 'serve' when hitServe fires. if (G.phase === 'toss' && !G.aim) { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } // --- player movement (near half only, generous margins) // 'rally' ONLY, deliberately. Letting the player walk during the between-point beats was tried // and vetoed on feel: you never walk freely during play, so suddenly being able to between points // reads as the game breaking, not as a fix. The frozen-changeover complaint is answered by // holding the DEAD instead (see updateZombies), and the stuck pose by the reset below. if (G.phase === 'rally') { if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // closest approach, for never-swung passes const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (!G.rallyNear || d < G.rallyNear.d) G.rallyNear = { d, y: ball.y, bz: ball.z, px: P.x, pz: P.z }; } // auto-fire steering is ABSOLUTE: the finger x IS the player x — zero lag, and clamped // to what the camera can see (portrait's world bounds are wider than its viewport) const mgMode = G.horde || G.mgT > 0; const xVis = xVisible(); let mgSteer = false, mgMoving = false; if (mgMode && touchPX() !== null) { mgSteer = true; const tx = Math.max(-xVis, Math.min(xVis, touchPX() * xVis)); if (Math.abs(tx - P.x) > 0.04) { P.facing = tx > P.x ? 1 : -1; mgMoving = true; } P.x = tx; } const slow = P.swingT >= 0 ? 0.35 : 1; const mx = mgSteer ? 0 : axisX(), mz = mgSteer ? 0 : axisZ(); P.x += mx * TUNE.playerSpeed * slow * dt; P.z += mz * TUNE.playerSpeed * slow * dt; let assist = 0; if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && !G.horde && !(G.mgT > 0)) { // auto-fire: you aim by hand, no assist // splatter: anything travelling back toward you is yours to run down — your own rebound // included. The assist puts you there; the volley timing stays honest. const chasing = ball.lastHit === 'O' || (G.mode === 'splatter' && !G.interlude && ball.vz > 0); // no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55); const tgt = chasing ? intercept('P') : HOME.P; // run to the ball, then recover home const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz); if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it P.x += dx / d * Math.min(rate, d); P.z += dz / d * Math.min(rate, d); assist = dx; } } // Stay ON SCREEN — always, not just in auto-fire (x = -6.5 projects to screen -61 of 270). const xLim = xLimit(); P.x = Math.max(-xLim, Math.min(xLim, P.x)); P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); // mgMoving MUST be in here. Finger-steering zeroes mx/mz (they are the keyboard axes), so a // player sliding left and right under auto-fire counted as standing still and never ran — // "when he's moving left and right the run animation is not applied". const moving = mgMoving || mx || mz || assist !== 0 || G.dodgeT > 0; P.runT += moving ? dt : 0; // Auto-fire used to be excluded here, which left the pose stuck on 'idle' — and render then // faked a run cycle off G.idleT so he was not a statue. That fake is what read as "this hectic // run animation" whether he was moving or not. The pose follows MOVEMENT in every mode now, so // auto-fire uses the ordinary run animation and standing still is standing still. if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle'; if (mx) P.facing = mx > 0 ? 1 : -1; else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing } // ...and out of 'rally' he STANDS. 'run' is the one pose nothing else expires (expirePose only // handles swing/serve), and P.runT stops advancing outside the block, so a beat that began // mid-stride left him welded to a single run frame — the "stuck mid-animation" report. else if (P.pose === 'run' && G.dodgeT <= 0) { P.pose = 'idle'; P.poseT = 0; } // The sidestep runs in EVERY phase — that is the entire point of it, since a boss fight is spent // in 'pretoss' where the block above never runs. Same clamp as the walk, so it cannot carry him // off the edge of the screen either. if (G.dodgeCd > 0) G.dodgeCd -= dt; if (G.dodgeI > 0) G.dodgeI -= dt; if (G.dodgeT > 0) { G.dodgeT -= dt; const xLim = xLimit(); P.x = Math.max(-xLim, Math.min(xLim, P.x + G.dodgeDir * (TUNE.dodgeDist / TUNE.dodgeT) * dt)); P.runT += dt; // the legs move, so the run cycle advances if (G.dodgeT <= 0) G.dodgeDir = 0; } // splatter: you START at the baseline but you're free to advance up-court (bounded at the net by // P_BOUNDS) — hunt the shamblers down, especially when mopping up a won game. Auto-assist still // recovers you toward the baseline between shots on touch; the duel is plain tennis as ever. if (P.swingT >= 0) { P.swingT += dt; if (G.mode === 'tennis' && ball.active && ball.lastHit === 'O') { // sample the approach for the miss log (t = what trySwing tests this frame) const volley = ball.bounces === 0; (G.swingSnaps ||= []).push({ t: P.swingT, d: Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), y: ball.y, bz: ball.z, bounces: ball.bounces, r: TUNE.reach * (volley ? VOLLEY.reach : 1), w: TUNE.swingWindow * (volley ? VOLLEY.window : 1) }); } trySwing(P, false); // a contact resets swingT to -1 inside trySwing // volley-length window while a hostile shot is in the air too, or the swing clock expires at // 0.24 s and closes TUNE.parryWindow (0.34 s) before it has had its chance const win = TUNE.swingWindow * ((shots.length || (ball.active && ball.bounces === 0)) ? VOLLEY.window : 1); if (P.swingT > TUNE.swingWindup + win + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); logWhiff(); } // log even if the point ended mid-swing } expirePose(P, dt); // --- zombies + transition timeline if (G.mode !== 'tennis') updateZombies(dt); tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then if (G.meleeT > 0) G.meleeT -= dt; if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out watchdog(dt); if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance if (G.mode === 'transition') transition(dt); // --- interlude trigger: the court is clear, an opponent holds the far court, you're about // to serve — the umpire recognizes a legal match and restores order. One point. Sudden death. // The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is // pure upside for weapon supply. if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight && G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled) startDuel(); if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt; // the duel is played under tennis lights: lerp the palette home while it runs, back after G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8)); // --- AI (the opponent only exists in tennis mode) if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') { G.aiServeT -= dt; if (G.aiServeT <= 0) tossBall(O); } else if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'toss') { if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); } } if (G.mode === 'tennis' && G.phase === 'rally') { G.aiReactT += dt; const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction; const tgt = incoming ? intercept('O') : HOME.O; const spd = TUNE.aiSpeed * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz); if (d > 0.1) { O.x += dx / d * Math.min(spd * dt, d); O.z += dz / d * Math.min(spd * dt, d); O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt; if (Math.abs(dx) > 0.25) O.facing = dx > 0 ? 1 : -1; } else if (O.pose === 'run') O.pose = 'idle'; G.aiSwingCool -= dt; if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) { // same radius trySwing uses for contact, so an armed swing actually connects const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach && ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0; if (near) { // rubber band: every game HE has taken makes him flub more — the opening match must not // be a wall a new player grinds against for rounds (see aiFlubLoss in config) if (Math.random() < TUNE.aiFlub + score.games[1] * TUNE.aiFlubLoss) G.aiSwingCool = 0.5; // whiff: hesitates and misses else { O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(O, true); O.swingT = -1; G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12; } } } } expirePose(O, dt); // --- racket zombies rally from the far court (splatter only, between hordes) if (G.mode === 'splatter' && !G.horde) for (const rz of zombies) if (rz.variant === 'r') racketAI(rz, dt); // --- ball physics if (ball.active) { // fire trail. One brief ember a frame read as a spark, not as a ball that is ON FIRE — this is // the weapon whose whole pitch is "kills on any touch and sets the others alight", so it now // sheds THREE embers a frame with real spread, they live long enough to hang as a tail behind // the arc, and every third leaves a puff of smoke drifting off the top of it. if (ball.flame) { for (let k = 0; k < 3; k++) parts.push({ x: ball.x + (Math.random() * 2 - 1) * 0.14, y: ball.y + (Math.random() * 2 - 1) * 0.14, z: ball.z + (Math.random() * 2 - 1) * 0.14, t: 0.34 + Math.random() * 0.26, fire: true, vx: (Math.random() * 2 - 1) * 0.9, vy: 0.9 + Math.random() * 0.7, vz: (Math.random() * 2 - 1) * 0.9 }); if (Math.random() < 0.34) parts.push({ x: ball.x, y: ball.y + 0.15, z: ball.z, vx: (Math.random() * 2 - 1) * 0.3, vy: 0.7, vz: (Math.random() * 2 - 1) * 0.3, t: 0.5 + Math.random() * 0.4, smoke: true }); } ball.vy -= (ball.g || TUNE.gravity) * dt; const oldZ = ball.z; ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt; // net: plane at netZ, only during rally flight if (G.phase === 'rally' && (oldZ - COURT.netZ) * (ball.z - COURT.netZ) < 0 && Math.abs(ball.x) < COURT.netW / 2) { const tCross = (COURT.netZ - oldZ) / (ball.z - oldZ); const yAt = ball.y - ball.vy * dt * (1 - tCross); // approx height at crossing if (yAt < COURT.netH) { ball.z = COURT.netZ + (oldZ < COURT.netZ ? -0.05 : 0.05); ball.vz *= -0.08; ball.vx *= 0.3; ball.vy = Math.min(ball.vy, 0); pock(0.4, 300); G.netRipple = { x: ball.x, t: 0.4 }; // the tape carries the hit: a damped wobble where it clipped } } if (ball.y <= 0 && ball.vy < 0) { ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY; ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ; if (Math.abs(ball.vy) > 1.0) dustPuff(ball.x, ball.z, 3, 0.7); // a real bounce kicks up the court onBounce(); if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball ball.active = false; if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel else resolveInterlude(ball.lastHit); } else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming spentBall(); sendBoy(); if (Math.random() < 0.12) voice('new_balls', 0.3); } else if (ball.bounces === 1) pointTo(ball.lastHit, null); } } // a racket zombie's return that gets past you (crosses your baseline) is a point for THEM if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) { ball.active = false; if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way } // the far backstop: a LIVE ball doesn't leave the field, it rebounds off the fence and comes // back down-court — still lethal on the way home, and yours to volley. Spent balls just die. if (G.mode === 'splatter' && !G.interlude && ball.active && ball.lastHit === 'P' && ball.z < TUNE.backstopZ && ball.vz < -3) { // only a ball with real pace left comes back; a roller just stops ball.z = TUNE.backstopZ; ball.vz *= -TUNE.backstopKeep; ball.vx *= 0.7; ball.vy = Math.max(ball.vy, TUNE.backstopPop); // the fence kicks it up, so it carries back through the crowd pock(0.5, 200); // chain-link thud } // splatter: retire spent or hopeless balls fast so the next serve comes right up. // z-bounds gate on travel direction — a serve born behind the baseline (P.z up to 26) // heads to the far court (vz<0), so it must not read as "out the back" on frame one. // (during the interlude only spent balls retire early — flying balls must reach their // bounce so the line call can happen; the umpire is watching again) if (G.mode === 'splatter' && ball.active && ((ball.lastHit === 'X' && ball.bounces >= 1) || (!G.interlude && ((ball.z < -2 && ball.vz < 0) || (ball.z > 25.5 && ball.vz > 0) || Math.abs(ball.x) > 7.5)))) { spentBall(); } if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0 && !G.aim) { // uncaught toss → retoss (a held slingshot aim keeps the toss up) ball.active = false; G.phase = 'pretoss'; if (score.server === 'O') G.aiServeT = 0.8; // dropping the toss twice running means the second tap isn't landing — the ghost hand then // rides the next toss and presses at the apex (render.drawApexHint) else if (G.mode === 'tennis') G.tossDrops++; } } // ball boy: sprint across, scoop the ball in passing, despawn off the far side const boy = G.boy; if (boy) boy.mop ? mopRound(boy, dt) : fetchRound(boy, dt); if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null; if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) { // A boss (or a duel) OUTRANKS the horde, and pendingHorde is a latch: it is armed when the set // banks and cashed by whichever 'over' beat expires next — which, at game 30, is the chapter // card, because 30 is both a set and a boss. The wave then poured onto the boss stage. Leave the // latch alone and let the beat AFTER the fight cash it: the set still gets its horde, later. if (G.pendingHorde && !G.bossFight && !G.interlude) { // the set card's pause is over — but the horde is a different CONTROL MODE G.pendingHorde = false; // (racket auto-fires, the finger steers you absolutely), if (!G.seen.horde) { // so its FIRST arrival gets a real block... G.seen.horde = true; showModal({ title: 'THEY KEEP COMING', color: UI.hot, body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'], onDismiss: startHorde }); } else startHorde(); // ...and every later set starts on the flash alone. Re-showing the // same popup at the hottest moment of every set was one of the // "popups get me killed" reports — you know the rules by set two. return; } // auto-fire modes must NEVER re-enter the serve loop: an 'over' beat expiring into // setupPoint() -> pretoss left the player locked in the serve posture while the gun // fired (no movement outside 'rally', no serving during auto-fire). Back to rally. if (G.horde || G.mgT > 0) { G.phase = 'rally'; ball.active = false; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; } else setupPoint(); } } }