// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. import { TUNE, UI, EATEN_BURST, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js'; import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js'; import { audio, pock, swish, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js'; import { axisX, axisZ, takeFlickAim, touchPX } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateZombies, spawnZombie, spawnRacketZombie, zombies, explode, meleeSwipe, extras, parts, playerGore } from './zombies.js'; // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g. export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity) { const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s; return { vx: dx / T, vz: dz / T, vy: (0.08 - 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 }; } // 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. export 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); 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; 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 if (Math.abs(dx * az - dz * ax) > TUNE.zSnap + TUNE.aimCone * along) continue; // outside the cone if (along < near) { near = along; zt = zb; } // the FIRST one in the lane eats it } 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'; // 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 const margin = 0.06 + 0.22 * quality; for (let i = 0; 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; ball.firstLand = null; 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; // only a consuming serve arms these; any other hit clears them if (ch.side === 'P') { // shot marker: where this will land — red means it's going out const ok = G.mode !== 'tennis' || (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) { G.hitstop = 0.05; G.flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); } ch.pose = 'swing'; ch.poseT = 0; } // ----------------------------------------------------------------- serve ---- function tossBall(sv) { ball.active = true; ball.bounces = 0; ball.firstLand = null; ball.collected = false; ball.x = sv.x + 0.35 * (sv.side === 'P' ? 1 : -1); 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'; } export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95 // how many 10%-pace cuts a shot may take to get over the net. 8 wasn't enough for a target // sitting right behind the tape — that needs a near-vertical drop, so ~10 cuts (pace × 0.35). const NET_LOFT_TRIES = 12; function hitServe(sv, quality, aim = null) { const toFar = sv.side === 'P'; let s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed; let boxX, tzs; const ft = G.mode !== 'tennis' && !G.interlude // the duel serves like tennis: to the box, not free-aim ? (aim && aim.fx !== undefined ? flickTarget(sv, aim) : { zt: zombieTarget(aim) }) : null; if (ft && ft.zt) { // magnet hit, or (tap) the most advanced zombie const zt = ft.zt; boxX = zt.x + (Math.random() * 2 - 1) * (1 - quality) * 0.8; const T0 = Math.hypot(zt.x - ball.x, zt.z - ball.z) / s; tzs = Math.min(23, zt.z + zt.v * T0); } else if (ft && ft.tx !== undefined) { // free-aim, no magnet: serve to the flicked spot boxX = ft.tx; tzs = ft.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 g = TUNE.gravity * (1 + quality * TUNE.serveSpin); // the harder you hit it, the harder it dips // CLEAR THE NET. A shambler standing just the other side of the tape is only reachable on a // loft, so trade pace for arc until the ball actually gets over. doHit has done this since // forever; the serve never did, which is why every shot at a net-hugger died in the tape. // The trade is real: the loftier it goes the slower it arrives, and the walk-lead below was // computed for the original pace — a lob is a genuinely less reliable shot. let v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g); for (let i = 0; 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; // 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) instead of doHit's 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(ball.x, ball.y, ball.z, boxX, tzs, s, g); } 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 // 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. const wsel = G.mode === 'splatter' ? G.weapon : 0; ball.bomb = wsel === 1 && G.bombs > 0; if (ball.bomb) G.bombs--; ball.flame = wsel === 3 && G.flames > 0; if (ball.flame) G.flames--; if (wsel === 2 && G.triples > 0) { // one serve, three balls: clones fanned ±6° G.triples--; for (const deg of [-6, 6]) { const r = deg * Math.PI / 180, c = Math.cos(r), sn = Math.sin(r); extras.push({ x: ball.x, y: ball.y, z: ball.z, vy: ball.vy, pow: quality, t: 2.5, vx: ball.vx * c - ball.vz * sn, vz: ball.vx * sn + ball.vz * c }); } } if (wsel > 0 && slotStock(wsel) <= 0) G.weapon = 0; // that was the last tin — hand back to plain balls if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: G.mode === 'tennis' && !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 (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; } } // ---------------------------------------------------------------- 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. showModal never clobbers an open one (a kill cascade can // try to open two in the same frame) and only ever touches G — never the zombie array it may // be called from mid-iteration. export function showModal(m) { if (!G.modal) G.modal = m; } export function dismissModal() { const m = G.modal; if (!m) return false; G.modal = null; if (m.onDismiss) m.onDismiss(); return true; // the caller must swallow the gesture: dismissing must never also serve } 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) crowd.cheer = Math.max(crowd.cheer, seconds); } 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) }); } 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. 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; voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak 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; if (side === 'P' && !zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it) // 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(z => z.variant !== 'r')) { 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 set/horde beat outranks it; 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') { G.pendingGame = true; startChangeover(); // 'GAME N' after the breather } else { // 'game': even-game beat, then GAME N announces the next wave G.pendingGame = true; ball.active = false; G.phase = 'over'; G.overT = TUNE.gameBeatT; } } // 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 = true; 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 G.flash = { text: 'CHANGEOVER', t: 1.4 }; // no tins here anymore — weapons are won in the duel, never handed out on a schedule } // ---- the courtside rack: five slots, PLAIN plus the four tins. Stock lives on G; slot 0 is // infinite plain balls. selectWeapon is the tap/key target — an empty special can't be picked. export const SLOTS = ['PLAIN', 'BOMB', 'TRIPLE', 'FIRE', 'GUN']; export const slotStock = i => [Infinity, G.bombs, G.triples, G.flames, G.guns][i]; export function selectWeapon(i) { if (G.interlude) return; // the sudden-death duel is played under tennis rules — plain balls only if (i > 0 && slotStock(i) <= 0) return; // dud: no tins of that kind on the rack G.weapon = i; pock(0.3, 1100); } // 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 WDESC = { // what the tin you just won actually does — one line, shown on the pickup modal BOMB: ['DETONATES ON CONTACT', 'CLEARS A WHOLE CROWD AT ONCE'], TRIPLE: ['ONE SERVE, THREE BALLS', 'FANNED WIDE ACROSS THE LANE'], FIRE: ['KILLS ON ANY TOUCH', 'AND SETS THE OTHERS ALIGHT'], GUN: ['HOLD THE LINE AND SPRAY', 'A BURST, NOT A SERVE'], }; export function deliverTin() { const w = G.deliveries++ % 4, add = k => Math.min(TUNE.wCap, k); if (w === 0) G.bombs = add(G.bombs + TUNE.bombMax); else if (w === 1) G.triples = add(G.triples + TUNE.tinTriples); else if (w === 2) G.flames = add(G.flames + TUNE.tinFlames); else G.guns = add(G.guns + TUNE.tinGuns); // machine-gun charges — a burst you pop when you choose, not on arrival if (G.weapon !== 0) G.weapon = w + 1; // equip the new tin — but never yank you off a deliberate PLAIN (banking) sendBoy(); voice('new_balls', 0.6); const name = SLOTS[w + 1]; showModal({ title: name, body: WDESC[name], icon: ['w_bomb', 'w_triple', 'w_fire', 'w_gun'][w], iconH: 40, color: UI.warm }); } // ---- 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.interlude = { introT: 2.4 }; // the intro beat: serving blocked while the umpire announces 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 }; } 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 — on the spot } 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 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 G.pendingGame = false; // the horde IS the announcement G.horde = { t: TUNE.hordeLen, spawnT: 0, mgT: 0 }; 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) voice('remain_seated', 0.3); G.flash = { text: 'THEY KEEP COMING', t: 1.2 }; } // hidden shortcut: tapping the umpire three times summons the horror early 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)); once('turn', 3.4, () => { 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'); }); 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(); // the mechanic changed under you — the one popup that gets an illustration, because it has // to TEACH the gesture, not just announce it showModal({ title: 'THE MATCH HAS CHANGED', color: UI.hot, icon: 'x_howto', iconH: 120, body: ['PULL BACK TO AIM — THEN LET GO', 'THE FURTHER YOU PULL, THE HARDER IT HITS', 'A WEAK SHOT ONLY STAGGERS THEM'] }); }); } 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); 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 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) { ball.firstLand = { x: ball.x, z: ball.z }; 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.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; } } } // GUN in the rack isn't a ball — committing a charge starts an mgT auto-fire burst (the same // mode a horde uses). With GUN selected, any serve gesture spends one charge instead of serving. function tryGunBurst() { if (G.mode !== 'splatter' || G.weapon !== 4 || G.guns <= 0) return false; G.guns--; if (G.guns <= 0) G.weapon = 0; // spent the last tin — back to plain balls G.mgT = TUNE.mgPickupLen; 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: 'GUN', t: 1.0 }; return true; } // slingshot serve, stage one: the pull-back starts the toss animation. The ball rises while // you aim (dotted line, render.js); the release (dragServe) fires it. Only from a fresh pretoss. export function serveToss() { if (score.server === 'P' && G.phase === 'pretoss' && !ball.active) tossBall(P); } // 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 (tryGunBurst()) 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 }); ball.active = true; ball.collected = false; ball.bounces = 0; ball.firstLand = null; ball.x = P.x + 0.35; ball.z = P.z; ball.y = 2.2; ball.g = TUNE.gravity; hitServe(P, pullQuality(aim.mag), aim); // a full pull is quality 1.0 — PERFECT, hitstop and all } export function keyPressed(code) { 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 && meleeSwipe(P.x, P.z, TUNE.meleeRange)) G.meleeT = TUNE.meleeCool; 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 (tryGunBurst()) return; // GUN selected: the serve gesture pops a burst, no ball 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'; } } // ---------------------------------------------------------------- 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 (was < EATEN_BURST && G.eatenT >= EATEN_BURST) playerGore(); // phase B: you become the gore if (G.shake && (G.shake.t -= dt) <= 0) G.shake = null; if (G.boomFlash > 0) G.boomFlash -= dt; updateZombies(dt); return; } if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; } if (G.card) { G.card.t -= dt; if (G.card.t <= 0) G.card = null; } if (G.shake && (G.shake.t -= dt) <= 0) G.shake = 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; return; } crowd.cheer = Math.max(0, crowd.cheer - dt); G.idleT += dt; const w = BOUNCE_IDLE.tempo; if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; } // --- player movement (near half only, generous margins) 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 = Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); let mgSteer = 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; P.runT += dt; P.pose = 'run'; } else P.pose = 'idle'; 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; } } const xLim = mgMode ? xVis : P_BOUNDS.x; // auto-fire keeps even keyboard players on screen P.x = Math.max(-xLim, Math.min(xLim, P.x)); P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z)); const moving = mx || mz || assist !== 0; P.runT += moving ? dt : 0; if (!mgSteer && 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 } // 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 const win = TUNE.swingWindow * (ball.active && ball.bounces === 0 ? VOLLEY.window : 1); // match trySwing's volley window 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); if (G.meleeT > 0) G.meleeT -= dt; if (G.clearing && !zombies.some(z => z.variant !== 'r')) 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.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) { if (Math.random() < TUNE.aiFlub) 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) { if (ball.flame) parts.push({ // fire trail: one ember per frame, brief and rising x: ball.x, y: ball.y, z: ball.z, t: 0.22, fire: true, vx: (Math.random() * 2 - 1) * 0.6, vy: 0.8, vz: (Math.random() * 2 - 1) * 0.6 }); 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); } } if (ball.y <= 0 && ball.vy < 0) { ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY; ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ; 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; } } // ball boy: sprint across, scoop the ball in passing, despawn off the far side const boy = G.boy; if (boy) { // mopping is not sprinting. `dist` is what drives his animation frames (render.drawBoy), so // the legs are tied to the ground he covers and can never out-run or lag behind the walk. const step = boy.dir * (boy.mop ? TUNE.boyMopSpeed : 7.5) * dt; boy.x += step; boy.runT += dt; boy.dist = (boy.dist || 0) + Math.abs(step); if (!boy.mop && !ball.collected && !ball.active) { // a mop boy has no ball to chase — he must hold his lane and clean it const dz = ball.z - boy.z; boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true; } if (Math.abs(boy.x) > 13.5) G.boy = null; // off the far wing — his round is done } if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null; if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) { if (G.pendingHorde) { // 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), showModal({ title: 'THEY KEEP COMING', color: UI.hot, // so it gets a real block, not just a card body: ['THE RACKET FIRES ON ITS OWN', 'SLIDE TO AIM · HOLD THE BASELINE'], onDismiss: startHorde }); return; } if (G.changeover) { G.changeover = false; voice('time', 0, 0.95); } // "Time." — play resumes // 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(); if (G.pendingGame && G.mode === 'splatter') { // the next wave is announced as it arms G.pendingGame = false; G.flash = { text: 'GAME ' + (G.runGames + 1), t: 1.1 }; } } } } }