// All mutable game state in one place, plus the point-reset that re-arms it. import { HOME, BOY_ENTER, TUNE, COURT, COURTS, TOURS, KITS, SKINS } from './config.js'; import { gore } from './zombies.js'; // runtime-only cycle (zombies imports state): sendBoy reads gore // to decide whether a mopping round is worth walking out for export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2, active: false, lastHit: 'P', bounces: 0, rx: null, heels: 0, approach: null, retreat: null, smash: false, spin: 1, soft: false, collected: false, kills: 0, bomb: false, flame: false, // NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the // striker's phone that is mid-roll, a whole court away from the call, and it read as the game // loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring // consequence below is switched off, and the ball dies where a real one would, by rolling to a // stop. Cleared by setupPoint with everything else that arms a point. scored: false, praise: null, // a perfect serve ARMS its praise word here; it pops at the impact, not the release // which weapon launched the ball in flight — the stats screen files a kill under it long after // the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free. wid: 'ball' }; export const score = { pts: [0, 0], games: [0, 0], server: 'P' }; // pts index 0=P 1=O // `pop` is the SIZE of the moment the stand is reacting to (cheer's own vol, 0..1) and is only // meaningful while cheer runs: it drives the wave, the paparazzi rate and how many shutters go off // at once. `burst` is that queue of shutters, drained by render.tickFlashes. export const crowd = { cheer: 0, pop: 0, burst: 0, dots: [] }; // dots are (re)built by render.buildCrowd // The court you picked on the start screen. Everything visual reads `court` directly; the two // bounce numbers are COPIED INTO TUNE instead, so the T panel still edits the live physics and // gameplay's three bounce sites stay untouched. export let court = COURTS[0]; export function selectCourt(id) { court = COURTS.find(c => c.id === id) || COURTS[0]; TUNE.bounceY = court.bounceY; TUNE.bounceXZ = court.bounceXZ; try { localStorage.setItem('court', court.id); } catch {} // private mode: a forgotten pick is fine return court; } // The tour you picked on the same screen. Nothing but the SPRITE NAME depends on it — see SK() in // render.js. A tour is a skin: same physics, same AI, same scoring. export let tour = TOURS[0]; export function selectTour(id) { tour = TOURS.find(t => t.id === id) || TOURS[0]; try { localStorage.setItem('tour', tour.id); } catch {} return tour; } // The kit and the skin, picked on the same screen and living by the same rule as the tour: nothing // but the DRAWN PIXELS depends on either. No physics, no AI, no scoring, no sprite NAMES — the // recolour happens after SK() has already chosen the frame (see tint/PS in render.js). export let kit = KITS[0], skin = SKINS[0]; export function selectKit(id) { kit = KITS.find(k => k.id === id) || KITS[0]; try { localStorage.setItem('kit', kit.id); } catch {} return kit; } export function selectSkin(id) { skin = SKINS.find(s => s.id === id) || SKINS[0]; try { localStorage.setItem('skin', skin.id); } catch {} return skin; } // everything that used to be loose top-level lets export const G = { mode: 'tennis', // tennis | transition | splatter transT: 0, // transition timeline clock tFlags: null, // one-shot markers within the transition hideO: false, // the opponent has... left the match everTransitioned: false, phase: 'pretoss', // pretoss | toss | rally | over serveNum: 1, overT: 0, hitstop: 0, flash: null, // {text, t} shake: null, // {t, mag} — camera shake, decays like flash (bomb impact) boomFlash: 0, // s of white flash frame left (bomb impact) lastOrderT: -9, // idleT of the last 'Order' call — bomb chains mustn't spam the umpire idleT: 0, boy: null, // ball boy sprinting across: {x, z, dir, runT} aiServeT: 0, net: null, // netplay (src/net.js): { ready, dead, side, code, opp } while a 1v1 runs aiSwingCool: 0, aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction wasServe: false, // the ball in flight is a serve (first bounce must hit the box) splatT: 0, // time survived in splatter mode — drives the difficulty ramp // The courtside rack. Slots 1-4 are NOT fixed weapon types anymore: they hold what you have // GATHERED, in pickup order, so which four you are carrying is a run-by-run thing. Stock still // ACCUMULATES per weapon (capped at TUNE.wCap). Slot 0 stays BALL — infinite, and how you bank a // special instead of spending it. rack: [], // up to RACK_MAX { id, n } — the gathered weapons, in the order they arrived temp: null, // { id, n } — a FIFTH weapon, riding slot 0 until it is spent. A barrel arms // itself on the spot, so it always has somewhere to go even with a full rack. weapon: 0, // selected slot: 0 = BALL (or temp, if one is riding it), 1..4 = G.rack[i-1] mgT: 0, // s of braced auto-fire left — no serving while it runs (gun/beacon/flamer, // and it doubles as the window a tap-fired laser or rocket is held for) mgCool: 0, // s until the next pellet in that burst gunId: null, // which WEAPONS row that burst is firing — rate, radius, damage, colour beam: null, // the laser's live wedge {x, z, ux, uz, t} — drawn, then gone. Not a projectile. testMode: false, // the scoreboard triple-tap sandbox: medium wave, every new weapon on the rack deliveries: 0, // tins delivered this run — indexes the weapon ladder duelDue: false, // the schedule called a duel — fire it at the next hand-cleared court afterDuel: null, // the game beat the scheduled duel interrupted ('changeover' | 'game'), resumed when it resolves aim: null, // live two-stage serve aim {fx, fy, mag} while the thumb is down (splatter pretoss) idleHintT: 0, // s the player has sat in pretoss without touching anything — after a beat the // pull gesture demonstrates itself (render.drawPullHint) meleeT: 0, // s of racket-swish cooldown left rackGlow: null, // {slot, t} — a freshly gathered barrel weapon pulses its rack tile briefly // instead of equipping itself (popBarrel keeps your current selection) passStreak: 0, // consecutive rally balls that passed untouched (tennis) — ≥2 shows TAP TO HIT flatStreak: 0, // consecutive returns hit with NO aim on them — the flick is the whole skill of // the mode and nothing on touch ever said so (render.drawFlickHint) // ---- the sidestep. The movement block is 'rally'-only on purpose, so this integrates OUTSIDE it // and is the only way to move in the serve stance (which is where a boss fight is spent). dodgeDir: 0, // -1 | 0 | 1 — which way the current step is going, 0 = standing dodgeT: 0, // s of the step left dodgeCd: 0, // s until another is allowed dodgeI: 0, // s of invulnerability left (checked by hurtPlayer AND the eat test) // ---- the SECOND damage channel. Only ranged attacks (ball machine, chapter boss) touch this; // a shambler reaching you is still instant permadeath, deliberately. No HP bar anywhere — the // state is drawn as blood on the man (drawChar's spatter + bleedPlayer's live trickle). hp: TUNE.playerHp, hpMax: TUNE.playerHp, hurtT: 0, // s of post-hit grace left; also drives the red hit flash hurtFlash: 0, // s of the impact PULSE left (the persistent damage vignette reads hp directly) placeMark: null, // {x, z, t} — where your aimed rally shot is going (match): the flick made visible swipe: null, // {fx, fy, mag} — the rally flick WHILE the thumb is still moving, so the width // its length is buying is visible before the ball leaves (input.js sets it) asideT: 14, // s until the umpire's next unsolicited remark (TUNE.asideEvery) praiseT: 0, // s until the next praise word is allowed (TUNE.praiseEvery) bossDue: 0, // chapter number the schedule owes you — fired at the next hand-cleared court bossFight: 0, // chapter number of the boss currently on court, 0 = none. Blocks all other spawns. slowT: 0, // s of REAL time left running the world in slow motion (a chapter boss dying) bossBurst: null, // {x, z, t} — the shockwave ring thrown off by that death interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving match: null, // ZOMBIE TENNIS (beats.startMatch): {opp, ending}. One GAME per opponent, // real scoring — every point you win wounds the racket zombie, every point it // wins draws your blood. Runs ON TOP of a permanent interlude. // CLASSIC TENNIS: the mode where nothing turns. `G.mode` stays 'tennis' for the whole run, the // transition is blocked, and the game is scored as a real match — 6 games with a two-game lead is // a set, TUNE.classicSets sets is the match. It is the FOUNDATION mode, not a warm-up: if the // core tennis is good, everything built on it is good. classic: false, classicWon: 0, // games YOU have taken off the CPU this classic match. G.sets is sets and // score.games resets every set, so nothing else counted this — and it is what // the classic world board ranks (a 12-2 win beats a 12-10 one). done: null, // {won, t} — the run ENDED without a corpse (a classic match played out). // The one end-of-run state that is not 'eaten': same summary, same gated tap. bench: null, // THE SPRITE BENCH (back room only): {i, stage, dir} — one opponent on an // empty court so a walk cycle can be judged at the size it is played at. // Owns the court like every other mode; see beats.startBench. drill: null, // the TRIAGE drill (src/drill.js): {t, spawnT, score, streak, kills, faults, // lost, best, msg}. Owns the court like every other mode — see beats.startDrill. // One rule: always kill the body that would reach you first. `t` is time // survived and drives the whole difficulty curve. killCount: 0, // splatter: kills banked toward the next point (see TUNE.killsPerPoint) league: 1, // THE LEAGUES: the profile's league, stamped by beats.startMatch for the // match modes (world = pace tier + band + court). 1 everywhere else, so // every non-league mode keeps playing the shipped game (leaguePace 1.0). runGames: 0, // games finished this run (either side) — the difficulty director sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde changeover: false, // the odd-game breather: no spawns, no serving, the boys mop clearing: false, // a game just ended: the surviving wave stays and must be killed by HAND before play advances (no auto-sweep). No new spawns, kills don't score. afterClear: null, // what happens once the court is clear: 'changeover' | 'game' | 'set' clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card) pendingHorde: false,// a set was won; the horde is WARNED after the set card's pause hordeWarn: 0, // s left of the THEY KEEP COMING breather; the wave lands when it expires (beats.warnHorde) matchFall: null, // {t, from} while a wounded match opponent collapses and drags itself up (beats.tickMatchFall) card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block' // the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns) // and only a tap or key clears it, so a popup can be read without being eaten while you read it. modal: null, // {title, body: [lines], icon, iconH, color, onDismiss} modalQ: [], // popups waiting behind the open one — see showModal seen: {}, // one-shot markers so a first-time-only modal fires once per run zoom: null, // {x, z, k, t, T} — a scripted punch-in on a world point (the transition's turn, a boss death) netRipple: null, // {x, t} — the ball clipped the tape; the net's top line wobbles there briefly best: null, // best run {games, sets}, loaded from localStorage when you get eaten newBest: false, // this run beat the stored best — the eaten summary celebrates it lastScoreT: -9, // idleT of the last score voice — rate-limits umpire during a horde eatenT: 0, // time since you were eaten — the death-animation clock, and it gates the restart tap eatenAt: null, // {x, z} where you fell — the gore mound blooms there missLog: [], // swing diagnostics ring buffer (tennis mode) — inspect via T.G.missLog swingSnaps: null, // ball-approach samples during the live swing, feeds the miss log rallyNear: null, // closest the incoming ball got this rally (for never-swung passes) swung: false, // player swung at least once this rally — whiff log supersedes PASSED lastMiss: null, // {text, at} — most recent miss reason, drawn on the HUD briefly moveTapT: 0, // the double tap's open window (gameplay.keyPressed, moveCall) // THE STORY's run state (beats.startStory). Wiped by restartRun like everything on G — the place in // the story lives in the profile (prof.story); this is only what THIS run is doing with it. story: null, // {act, part: 'cup'|'chap', base, midDone, fight: 'mid'|'end'|null, winT} storyBoss: null, // 'mid' | 'end' — a story boss is due at the next clear (splatPoint arms it) ring: null, // the drawn gold ring's steady cue (gameplay update smooths ringCue) logShow: false, // tap the scoreboard to toggle the on-screen miss-log dump }; // A pristine deep copy of every mutable bag, taken at module load before anything can touch them. // This is what replaced `location.reload()` on death: the reload was an honest full reset, but it // also tore down the AudioContext, so the menu theme restarted from silence every time you died. // Everything above is plain data — primitives, nulls, small arrays and {} — so a structured clone // is the whole job. const PRISTINE = structuredClone({ G, P, O, ball, score }); export function resetState() { const p = structuredClone(PRISTINE); Object.assign(G, p.G); Object.assign(P, p.P); Object.assign(O, p.O); Object.assign(ball, p.ball); Object.assign(score, p.score); G.hp = G.hpMax = TUNE.playerHp; // re-read: the T panel may have moved it since boot crowd.cheer = crowd.pop = crowd.burst = 0; } export const other = s => s === 'P' ? 'O' : 'P'; export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side export function setupPoint() { G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; ball.rx = null; ball.heels = 0; P.chase = P.goal = P.goalKey = P.post = null; P.running = false; P.rushing = false; ball.approach = null; ball.retreat = null; ball.smash = false; G.swingSnaps = null; G.rallyNear = null; G.swung = false; if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing // (the MATCH falls through to the tennis branch below: // real tennis serves from behind the baseline, deuce side // mirroring and all — serving from mid-court where the // last rally left you read as a bug, because it was one) score.server = 'P'; P.pose = 'idle'; P.swingT = -1; P.facing = 1; return; } const sv = score.server === 'P' ? P : O, rc = score.server === 'P' ? O : P; const sx = deuceSide() ? 1.3 : -1.3; sv.x = sv.side === 'P' ? sx : -sx; // deuce side mirrors across the net sv.z = sv.side === 'P' ? 24.3 : -0.5; // A RECEIVER STANDS BEHIND HIS BASELINE (Oliver, 2026-08-22: "when the opponent serves the // player does not stand at the baseline — but that's how it should be"). Both marks used to sit // over a metre INSIDE the court (22.5 near, 1.2 far), which is where you stand to return a second // serve, not a first one — and next to a server drawn behind his own line it read as a man who // had wandered in. They now mirror the server's depth: 0.4-0.5 m behind the line on both sides. rc.x = -sv.x; rc.z = rc.side === 'P' ? 24.2 : -0.43; P.pose = O.pose = 'idle'; P.swingT = O.swingT = -1; P.facing = O.facing = 1; // canonical righty stance — bounce/serve sprites must not render mirrored G.aiServeT = score.server === 'O' ? 1.4 : 0; } // fetch a dead ball — or, in splatter, come out to CLEAN: a different sprite sheet and a slower // walk, because mopping is not sprinting. Mopping is the DEFAULT once the gore starts; the plain // running fetch-boy survives for tennis mode, where there is nothing on the grass but a ball. // Is there a stain worth walking out for? Must match mopRound's own target test, or he walks on, // finds nothing he can reach, and walks straight off again — which is exactly what Oliver saw: // "the mopboy sometimes moves over the court without anything to mop". // ...and in a MATCH the mess is on the FAR side, because that is where the body stands and bleeds // through four wound stages. This test was written for the gore mode, where everything dies on the // near court, and it silently made the boy impossible in the match: sendBoy was called, found no // stain it considered worth walking out for, and returned. The near-side bias is right everywhere // else, so the match is the exception rather than the rule being widened. const mopWorthIt = () => gore.some(d => d.a >= 0.15 && (G.match || d.z >= COURT.netZ - 2)); export function sendBoy(tx = ball.x, tz = ball.z, mop = G.mode === 'splatter') { // The sudden-death duel is clean tennis: nobody mops through it. THE MATCH IS THE EXCEPTION — it // runs on a PERMANENT interlude, so this guard silently barred the boy from an entire mode, and a // match is where the gore actually accumulates: one body bleeds through four wound stages in the // same spot, game after game. He is let out between games only (matchGameWon calls him), never // mid-point, which is the same "guaranteed calm court" rule the modals live under. if (G.interlude && !G.match) return; if (G.drill) return; // ...and nobody walks onto a drill. He is one more body crossing the // lanes you are reading colours down, and unlike everything else out // there he is not part of the puzzle OR the threat — pure noise. if (mop && !mopWorthIt()) return; // no job, no boy if (tz < -3 || tz > 27) return; // target left the field lengthwise, let it go // ONE boy, one slot. Every kill used to call this and teleport him back to the wing mid-stride, // which is why he flickered in and out: a kill streak restarted him several times a second. // A boy already out on the court keeps working — he just adds the new mess to his round. if (G.boy) { if (mop) { G.boy.mop = true; G.boy.jobs = Math.min(TUNE.boyMaxJobs, (G.boy.jobs || 1) + 1); } return; } const dir = tx > 0 ? 1 : -1; // enter from the opposite side so the pickup is mid-run const e = mop ? BOY_ENTER.mop : BOY_ENTER.run; G.boy = { x: dir > 0 ? -e : e, z: tz, dir, runT: 0, dist: 0, mop, jobs: 1 }; }