// THE BEAT MACHINERY — every transition between the modes that can OWN THE COURT lives here: // the sudden-death duel (G.interlude), the horde (G.horde), the chapter boss (G.bossFight), the // changeover breather (G.changeover), and the clear-advance that hands play from one to the next // (proceedAfterClear). THE RULE: one mode owns the court at a time, and every start* function // enforces that itself — a caller never has to know what else is running. All three field freezes // came from two of these overlapping; if you add a mode, it starts here and it refuses here. // (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as // input <-> gameplay: the imported bindings are only touched inside functions, never at load.) import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, CUP_ROUNDS } from './config.js'; import { endRun, MODE_MATCH } from './stats.js'; import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js'; import { voice, zombieDuck, aww } from './audio.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js'; import { startDrillStream } from './drill.js'; import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon // 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', 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 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() { // Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while // another control mode owns the court. Each of these has ended a run when they overlapped. if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) 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 || G.drill || G.match || G.bench || G.net) 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 aside('ump_boss_arrives'); voice('sudden_death', 2.6, 0.85); // the stake is an event; the quip is benched (see aside()) const art = bossArt(chapter); // THE WALK-ON IS AN OVERLAY, not a band card. A card announced the biggest fight in the game in // the same slot and size as a routine set, and it read as the boss simply being there. This is on // the sanctioned modal list (a boss stage is guaranteed calm — the two lines above just cleared // it), and it HOLDS itself for bossIntroT rather than waiting for a tap: the point is to prepare // the player, and demanding a tap to start a fight he didn't ask for is the control steal the // popup policy exists to prevent. A tap still skips it. G.card = null; G.flash = null; showModal({ title: art.name, color: UI.bad, hold: TUNE.bossIntroT, body: ['ENTERED THE COURT'] }); // (the four-line machine instruction modal is gone — people figure it out) } // 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: 'YOU DID IT!', 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 COACHING DRILL: the cognitive mode. Same contract as every mode above — it refuses to // start while anything else owns the court, and it clears the court it takes over, so nothing that // was walking around a moment ago is still walking around inside a drill round. The drill's own // clock and rules live in drill.js; only the transition is here, where the rule can be enforced. export function startDrill() { if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench || G.net) return false; G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null }; G.changeover = false; G.clearing = false; G.afterClear = null; // ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending // horde, an owed chapter or a due duel is a latch that keeps trying to fire behind the drill, // and the mode it belongs to has no bodies of its own here to fire at. G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false; G.mgT = 0; endBurst(); pellets.length = 0; extras.length = 0; shots.length = 0; G.boy = null; // nobody mops through a drill G.weapon = 0; // plain balls: the mode is about order, not about the rack ball.active = false; setupPoint(); voice('quiet_please', 0.1); startDrillStream(); return true; } export function endDrill() { if (!G.drill) return; G.drill = null; zombies.length = 0; ball.active = false; setupPoint(); } // ---- THE SPRITE BENCH: the back room's art test, and nothing else. No ball, no scoring, no AI — // ONE opponent on an empty court, at the size and the speed it is actually played at, because that // is the only place a walk cycle can be judged. A sheet that looks fine at 224 px on a contact sheet // is a different animal at 30 px moving across a lawn, and every sprite argument this project has // had was really an argument about that gap. // // Each stop is either an ANIMATED state (the walk, driven by ground covered like the real thing) or // ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be looked // at the day they are generated rather than the day they are wired up. const BENCH_HP = 12; // a deep pool so a wound stage is set by FRACTION, cleanly const BENCH_CHARS = ['o', 'of', 'p', 'pf']; // the living slots past ZFAMS, in walk order export function startBench() { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; G.bench = { i: 0, stage: 0, fam: 0, dir: 1 }; zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // ...and DISARM the ordinary schedule, exactly as the drill does: a pending horde or an owed // chapter is a latch that keeps trying to fire behind a mode that has no bodies for it. G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false; G.changeover = false; G.clearing = false; G.afterClear = null; G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null; ball.active = false; setupPoint(); G.flash = null; G.card = null; G.modal = null; G.modalQ.length = 0; // nothing the run was // saying belongs over an art test — the bench's own // readout lives in that band spawnRacketZombie(); benchApply(); return true; } export function endBench() { if (!G.bench) return; G.bench = null; zombies.length = 0; ball.active = false; setupPoint(); } // hp is set as a FRACTION of the pool, never as a literal, because that is how drawZombie reads a // wound stage — the bench must ask the same question the game asks or it is testing something else. function benchApply() { const zb = zombies.find(z => z.variant === 'r'); if (!zb) return; // the four slots past ZFAMS are the LIVING players: the two opponents, then the two PLAYERS // (the run cycles are re-rolled from the same script and judged the same way — a player cycle // that was only ever seen in a rally is a cycle nobody has actually watched) G.bench.char = G.bench.fam >= ZFAMS.length ? BENCH_CHARS[G.bench.fam - ZFAMS.length] : null; zb.hpMax = BENCH_HP; zb.hp = Math.max(1, Math.round(BENCH_HP * [1, 0.5, 0.2][G.bench.stage])); // ...and the named states take the wound stage with them: z_r at full health, // z_rw1 / z_rw2 below it. render falls back to the healthy frame if a stage has // not been drawn yet, and the HUD says so — a bench must never imply art that does not exist. const f = ZFAMS[G.bench.fam] || 'r'; zb.fam = f; const suf = BENCH[G.bench.i].spr; zb.showFrame = suf ? 'z_' + f + (G.bench.stage ? 'w' + G.bench.stage : '') + suf : null; } // THE FALL BUTTON PLAYS THE REAL BEAT. Same FALL table, same tickMatchFall — the bench must never // re-implement a timing it exists to judge, or it would be refining a second copy of it. `from` is the // stage the body collapses IN; the rise reads racketStage(zb) off the hp the bench already set, so the // sequence wears the wound stage currently on screen. Press it again to replay. export function benchFall() { if (!G.bench || G.matchFall) return false; const zb = zombies.find(z => z.variant === 'r'); if (!zb) return false; G.matchFall = { t: 0, from: G.bench.stage, vx: zb.vx || 0, x0: zb.x }; return true; } // the bench's own controls, taken before anything else reads the key: SPACE / a tap steps to the // next state, left/right change the wound stage. One gesture, one meaning — the rule this project // already holds itself to on touch. export function benchKey(code) { const b = G.bench; if (code === 'ArrowLeft' || code === 'KeyA') b.stage = (b.stage + 2) % 3; else if (code === 'ArrowRight' || code === 'KeyD') b.stage = (b.stage + 1) % 3; // up/down walks the OPPONENT FAMILIES — plus two LIVING slots past the end (the tennis // opponents m/f, Oliver's run-cycle finetune bench): the zombie steps aside and the real O runs else if (code === 'ArrowUp' || code === 'KeyW') b.fam = (b.fam + ZFAMS.length + BENCH_CHARS.length - 1) % (ZFAMS.length + BENCH_CHARS.length); else if (code === 'ArrowDown' || code === 'KeyS') b.fam = (b.fam + 1) % (ZFAMS.length + BENCH_CHARS.length); // F plays the fall. Before the fallback below, which treats ANY other key as "next state" — a new // control that forgets to land above that line silently steps the stop instead. else if (code === 'KeyF') return benchFall(); else b.i = (b.i + 1) % BENCH.length; benchApply(); } export function updateBench(dt) { const b = G.bench; if (!b) return; const zb = zombies.find(z => z.variant === 'r'); if (!zb) return; const item = BENCH[b.i]; zb.z = 5.5; // parked on the far court, where an opponent stands zb.swingCool = 1; zb.swingT = -1; zb.poseT = 0; // nothing expires a pose out from under the bench // ...and a body that is COLLAPSING is not pacing. tickMatchFall owns the frame while the fall runs, // so the walk stop must stop walking or the corpse slides sideways through its own death. if (G.matchFall) return; // THE LIVING SLOTS: the zombie waits outside the frame while the real opponent runs the same // back-and-forth the walk stop uses — same speed class as the classic CPU, so the cadence being // judged is the cadence the game plays at. Every stop shows the run; the run is what the bench // slot exists to tune. if (b.char) { zb.x = 40; zb.vx = 0; zb.pose = 'idle'; // 'p'/'pf' pace the PLAYER on the near court instead — same back-and-forth, his own end, and // the sprite is twice the size there, which is the whole reason his cycle needs its own stop. const ch = b.char[0] === 'p' ? P : O; G.hideO = b.char[0] === 'p'; // splatter hid him when the match turned; the opponent ch.z = ch === P ? HOME.P.z : 5.5; // stops un-hide, the player stops leave him out const step = TUNE.aiSpeed * 0.7 * dt; ch.x += b.dir * step; ch.runT += dt; if (Math.abs(ch.x) > 4.0) { b.dir *= -1; ch.x = Math.sign(ch.x) * 4.0; } ch.facing = b.dir; ch.pose = 'run'; if (ch === P) { O.pose = 'idle'; O.x = HOME.O.x; O.z = HOME.O.z; } return; } O.pose = 'idle'; O.x = HOME.O.x; O.z = HOME.O.z; G.hideO = true; // fam stops: the living man steps back out if (item.walk) { // THE STRIDE STEPS OFF GROUND COVERED, like the real one — a bench that animated on a clock // would be flattering exactly the sprites that foot-slide in play. const step = TUNE.racketSpeed * 0.55 * dt; zb.x += b.dir * step; zb.vx = b.dir * step / dt; // so a FALL from here inherits the run, as in a match zb.dist = (zb.dist || 0) + step; if (Math.abs(zb.x) > 4.0) { b.dir *= -1; zb.x = Math.sign(zb.x) * 4.0; } zb.face = b.dir; zb.pose = 'run'; } else { zb.pose = item.pose || 'idle'; zb.x = 0; zb.vx = 0; } } // ---- 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 = ''; export function watchdog(dt) { if (G.mode !== 'splatter' || G.modal) { stuckT = 0; return; } // a popup waits on the PLAYER: legitimate // INVARIANT NET, before the clock-based test — because the clock-based test is blind to exactly // this: a horde's own timer keeps ticking, so the sig always "moves" while the player is welded // in place. Two states the guards above should make impossible; if a path still reaches them, // repair on the spot and leave evidence in the miss log. if (G.horde && G.interlude) { // a duel inside a horde: no steering, no serving, spawns keep coming logSwing({ result: 'INVARIANT', reason: 'duel active during horde — interlude dropped, duel re-scheduled' }); G.interlude = false; G.duelDue = true; G.duelLerp = 0; } if (G.horde && G.phase !== 'rally') { // pretoss under a horde = the finger steers nothing logSwing({ result: 'INVARIANT', reason: `phase '${G.phase}' during horde — forced back to rally`, phase: G.phase }); G.phase = 'rally'; ball.active = false; P.pose = 'idle'; P.poseT = 0; P.swingT = -1; } 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.hordeWarn = 0; G.aim = null; endBurst(); setupPoint(); stuckT = 0; stuckSig = ''; } // ---- ZOMBIE TENNIS: the exhibition match. One GAME per opponent, real 15/30/40 scoring, and the // score IS the body — every point you win tears a wound stage off the racket zombie (the same // fraction-of-hpMax skins the duel uses), every point it wins draws your blood (the ordinary hp // channel; at zero you are eaten as usual). Win the game and the corpse comes apart; a fresh // opponent shambles on with more hp. Games won stays the ladder metric. // // Deliberately built ON TOP of the duel machinery rather than beside it: G.interlude is what makes // serving tennis-like, keeps the reticle and the rack away, quiets the spawn director and wakes // racketAI — all already true. The ONE difference is where a finished point goes: resolveInterlude // hands it to matchPoint instead of ending anything, and the interlude simply never resolves until // the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds // it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which // lands in matchGameWon via the empty-court check). // `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is // optional and null means "roll each round as it comes", which is what the mode did before the // screen existed and is still what the #cup back door gets. export function startMatch(cup = false, draw = null) { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; G.match = { opp: 1, ending: false }; // THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks // on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream // scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies // by the round's `need`), and where a won game goes (cupGameWon). See config.CUP_ROUNDS. if (cup) { G.match.cup = { round: 0, g: 0, draw: draw || null }; cupDraw(); } G.matchFall = null; G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore // mode's 4 was "too weak" here, felt not calculated score.server = 'P'; // you serve every point (the corpse never learned to toss) G.weapon = 0; G.mgT = 0; endBurst(); pellets.length = 0; extras.length = 0; shots.length = 0; zombies.length = 0; G.boy = null; score.pts = [0, 0]; matchOpponent(); matchReady(); zombieDuck(true); voice('play_resume', 0.2); if (cup && !G.seen.cup) { G.seen.cup = true; showModal({ title: 'THE TOURNAMENT', color: UI.hot, body: ['FOUR ROUNDS UP THE DEAD HIERARCHY', 'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] }); } else if (!cup && !G.seen.match) { G.seen.match = true; // Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real // number, so the modal can never drift from the pool it describes. showModal({ title: 'DEATH MATCH', color: UI.hot, body: ['WIN THE POINT — WOUND THE ZOMBIE', 'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] }); } return true; } // The round's rung, drawn from its band. d.opp carries it so matchFoe, the ladder speed dials and // the felled arithmetic all read the drawn rung with no cup-awareness of their own. // A DRAW MADE ON SCREEN IS THE DRAW THAT IS PLAYED. When the bracket screen settled the rungs they // arrive here as cup.draw and this simply reads them off — rolling again would mean the faces the // player watched land had nothing to do with what walks on, which is worse than showing nothing. // Guarded on the band all the same: a saved or hand-passed draw that names a rung this round does // not contain falls back to an honest roll rather than dealing the champion in round one. function cupDraw() { const cup = G.match.cup, R = CUP_ROUNDS[cup.round]; const drawn = cup.draw && cup.draw[cup.round]; cup.rung = R.band.includes(drawn) ? drawn : R.band[Math.floor(Math.random() * R.band.length)]; G.match.opp = cup.rung; } // Every point starts like a real one: the server behind his line (setupPoint's tennis branch) and // THE RECEIVER ON ITS MARK — the zombie is dropped onto the receiver position the tennis branch // just computed for O, instead of standing wherever the last rally abandoned it. Oliver: "it spawns // at places where it is impossible to react to my serve. so i win like always." An opponent that // cannot reach the serve is not weak, it is absent. // ...and G.serveNum resets HERE, because the match bypasses tennis's pointTo (the only other place // that resets it). Without this, the first fault of the match armed 'second serve' PERMANENTLY: // every missed serve after it was a double fault, a point, and blood — the death loop Oliver hit. export function matchReady() { // exported for net.js: a paired death match re-places both bodies ball.active = false; G.serveNum = 1; if (G.match) G.match.decided = false; // the next rally may be decided early again setupPoint(); const rz = zombies.find(z => z.variant === 'r'); // THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline // when it is the one serving. setupPoint has just placed O — the invisible tennis body the serve // machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for // the mark, which is what stops the picture and the ballistics disagreeing about where it stands. if (rz) { rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0; rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; rz.showFrame = null; rz.serveHold = 0; } } // ---- THE FALL. A point won knocks the body DOWN and it drags itself back up MINUS THE LIMB. // // It exists to explain a thing the mode was doing silently: the wound stage POPPED between points, // so an arm simply stopped being there and nothing said why. Now the three collapse frames are the // body it STILL WAS and the rise is the body it has BECOME — the order is the whole beat, and it is // what makes the missing arm read as something the point did rather than a sprite swap. // // On its OWN clock, deliberately, and not on the 'over' beat it also sets. Any beat that moves the // phase underneath a latch strands it forever — that is the mgT lock this file already carries a // section about, and the horde's warning is on its own clock for the same reason. The 'over' beat is // only there to keep the court quiet, and it is set to outlive the fall so it cannot expire into // setupPoint() mid-collapse and hand the player a serve over a body that is still on the ground. // fall3 is the one that HOLDS: "the opponent should fall and then lie there for a quick moment, the // player stands up too quick." A corpse that touches the ground and is instantly upright reads as a // stumble; the beat only lands if it is down long enough to look beaten. // "fall quicker. lie there for 2s then stand up." The collapse is now a drop — two frames in a fifth // of a second — and the time is spent on the floor, where it reads. // EXPORTED AND MUTABLE, so the sprite bench's FALL button can be tuned against the art while it // plays — refining these four numbers IS the job that button exists for, and a bench that could not // reach them would only prove the beat runs, not that it feels right. // TUNED IN #bench WITH THE FALL BUTTON, watching it against the art rather than reasoning about it. // fall1 and fall2 are deliberately the SAME PACE (0.87 and 0.88 m/s of slide at a 1.54 m/s entry), so // the collapse reads as one continuous movement decelerating into the ground; the 0.07 s fall2 it // replaced gave the two drawn frames ~4 display frames each and read as a cut, and a 0.40 s one split // them 0.80 against 0.56 m/s, which is a visible hitch between two poses of the same drop. // 2026-08-13, after the early-fall retiming: "the progression of frames in the fall must be // quicker... we must remove the rise frame, the opponent respawns anyway... the lying itself can // stay that long." So: the DROP tightened (0.06+0.25 -> 0.05+0.15 — the collapse now plays // against a live rolling ball, and the slower drop tuned for a dead court read as mush next to // it), the 2 s prone hold KEEPS its bench-tuned length, and the rise row is CUT: done() puts the // body on its mark in its wound skin anyway, and rising just to be teleported read as a respawn // with extra steps. The z_*rise frames stay in the sheets/preload — the bench can still show them. export const FALL = [['fall1', 0.05], ['fall2', 0.15], ['fall3', 2.0]]; // Read LIVE by startFall, because a snapshot goes stale the moment the table is tuned and the match's // 'over' beat would then expire mid-collapse — the exact bug the comment above is about. export const fallLen = () => FALL.reduce((s, f) => s + f[1], 0); export const FALL_LEN = fallLen(); // the load-time value test.js imports as a loop bound function startFall(from) { const fz = zombies.find(z => z.variant === 'r'); G.matchFall = { t: 0, from, vx: (fz && fz.vx) || 0, x0: (fz && fz.x) || 0 }; // an EARLY-decided point keeps its ball: the whole reason the fall starts now is so the ball // visibly rolls past the collapsing body (gameplay's matchOutOfReach call). The rally cannot // resolve twice — the d.decided latch swallows every later resolution of it. if (!G.matchEarly) ball.active = false; G.phase = 'over'; G.overT = fallLen() + 0.6; // outlives the fall on purpose — see above } // Ticked from update(), beside the horde's warning, for the same reason it is. It also drives the // OPPONENT'S SERVE POSES, because both are the same job — naming the frame while the body is doing // something the walk cycle has no picture for. export function tickMatchFall(dt) { // THE MOP BEAT runs here, on the same clock discipline as the fall: its own timer, never the // 'over' beat (see the section note above). When the boy's time is up the next opponent walks // on with its own intro card, exactly as it used to on the kill frame. const d = G.match; if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) { // Time is up — but THE COURT IS HIS UNTIL HE LEAVES IT (Oliver: "no playing as long as the // mopboy is on court"). He is ordered off (jobs zeroed, the half-pushed stain dropped) and // the walk-on holds until he is clear of the playing area; the 6 s cap is a belt on top of // the braces — nothing should ever need it, but a stalled beat must not stall the match. const b = G.boy; if (b && b.mop && Math.abs(b.x) < 6.5 && (d.mopHold = (d.mopHold || 0) + dt) < 6) { b.jobs = 0; b.tx = undefined; b.tz = undefined; b.mopT = 0; if (G.interlude && G.interlude.introT < 1) G.interlude.introT = 1; // serving stays barred d.mopT = 0.001; // the beat re-checks next tick } else { d.mopT = 0; d.mopHold = 0; matchOpponent(); d.ending = false; // play re-opens only now that a body is on court matchReady(); G.phase = 'over'; G.overT = 2.2; // matchReady's setupPoint re-arms pretoss; keep the old beat } } if (!G.matchFall) return tickMatchServe(dt); const f = G.matchFall; const zb = zombies.find(z => z.variant === 'r'); // THE BENCH OWNS THE COURT WHEN IT IS OPEN, so neither exit hands a point back: matchReady() runs // setupPoint() and re-places the body on its receiving mark, which is a MATCH doing match things // inside an art test. Same one-mode-owns-the-court rule the start* functions all obey. const done = () => { G.matchFall = null; if (zb) zb.showFrame = null; if (!G.bench) matchReady(); }; if (!zb && !f.kill) { G.matchFall = null; if (!G.bench) matchReady(); return; } // felled by something else mid-fall f.t += dt; // A GAME-WINNING FALL DOES NOT GET UP. The explosion used to land on the same frame as the point, // so the collapse never played (Oliver: "it would be better if the explosion would happen after // the fall"). Now the body drops, holds a beat prone, bursts — and only THEN does the deferred // finish run (d.afterFall: the heal card, the mop beat, the walk-on), so the WOUNDS TREATED // popup lands after the burst instead of covering it. if (f.kill) { const boomAt = FALL[0][1] + FALL[1][1] + TUNE.matchBoomHold; if (!f.boomed && (!zb || f.t >= boomAt)) { f.boomed = true; if (zb) explode(zb.x, zb.z); } if (f.boomed) { if (f.t < boomAt + TUNE.matchBoomCard) return; // let the burst be seen before any card G.matchFall = null; const fin = d.afterFall; d.afterFall = null; return fin && fin(); } } let acc = 0, cur = null; for (const [suf, len] of FALL) { acc += len; if (f.t < acc) { cur = suf; break; } } if (!cur) return done(); // back on its feet: hand the point back // ...and this is the line the beat is for: the collapse wears the OLD stage, the rise wears the // NEW one. drawZombie's showFrame override degrades a missing stage to the healthy frame, so a // family whose fall rows have not been drawn yet still plays the beat instead of blinking out. // A BODY RUNNING FOR THE BALL DOES NOT STOP DEAD TO FALL OVER. It carries the run into the collapse // and skids out of it — Oliver: "when the opponent runs to the ball from left to right and then // falls, the player must still move to the right while falling." The velocity is INHERITED (racketAI // and updateBench both record zb.vx), so a body that went down standing still does not slide at all // and one caught at a sprint slides furthest. // // THE SLIDE IS A DISTANCE BUDGET SHARED OUT ACROSS THE FRAMES, not a decaying velocity. It was // exponential decay first, and that put 93% of the travel in the first two frames: "the visible // slide should also extend to the 3rd and 4th frame, between those the slide is barely noticeable." // A wall-clock decay cannot fix that by retuning — stretch it far enough to move the prone body and // it creeps for the whole 1.8 s floor hold, which is a corpse on ice. So each frame gets an explicit // SHARE of the total (TUNE.fallSlideMix), eased out within its own segment so it still decelerates: // the drop carries, the landing skids, and the rise drags itself the last of the way. // x is set ABSOLUTELY from the start position rather than integrated, so the total cannot drift with // frame rate and the same fall lands in the same place every time. if (f.vx) { const mix = TUNE.fallSlideMix, total = f.vx * TUNE.fallSlideDist; let acc2 = 0, seg = 0, segStart = 0; for (let i = 0; i < FALL.length; i++) { // which segment, and how far into it if (f.t < acc2 + FALL[i][1]) { seg = i; segStart = acc2; break; } acc2 += FALL[i][1]; seg = i; segStart = acc2 - FALL[i][1]; } const len = FALL[seg][1] || 1e-6; const u = Math.max(0, Math.min(1, (f.t - segStart) / len)); const ease = 1 - (1 - u) * (1 - u); // ease-out: quick, then settling let done2 = 0; for (let i = 0; i < seg; i++) done2 += mix[i] || 0; const travelled = total * (done2 + (mix[seg] || 0) * ease); zb.x = Math.max(-COURT.dblW / 2, Math.min(COURT.dblW / 2, f.x0 + travelled)); } const st = cur === 'rise' ? racketStage(zb) : f.from; zb.showFrame = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '') + cur; zb.pose = 'idle'; zb.swingT = -1; } // ---- THE OPPONENT SERVES. Alternating service is real tennis, and it doubles the mode's skill // surface: until now the corpse only ever RETURNED, so return timing — half of the sport — was never // asked for. Almost all of it already existed and was switched off: setupPoint's tennis branch // already places a non-player server behind its own baseline and arms G.aiServeT, and update() // already tosses and strikes for O. Both were gated on `G.mode === 'tennis'`, and the match runs in // splatter with a permanent interlude, so the whole machine was one condition away from running. // // What is genuinely new is the PICTURE. O is invisible here — the body you see is the zombie — so // the serve poses have to be named onto it, exactly as the fall names its collapse: bounce while it // waits, toss as the ball goes up, and the strike held briefly after contact so the swing reads as // something the body did rather than a ball that simply departed. function tickMatchServe(dt) { if (!G.match || score.server !== 'O') return; const zb = zombies.find(z => z.variant === 'r'); if (!zb) return; if (zb.serveHold > 0) zb.serveHold -= dt; const st = racketStage(zb); const pre = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : ''); zb.showFrame = zb.serveHold > 0 ? pre + 'serve' : G.phase === 'pretoss' ? pre + 'bounce' : G.phase === 'toss' ? pre + 'toss' : null; } function matchOpponent() { // NETPLAY: THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY (Oliver: "it should be the normal // opponent"). Nothing spawns, nobody is renamed, no walk-on card — the living O carries the // whole picture and the score reads on it as BLOOD (bleedO in zombies.js), because the human // sprite has no wound skins to climb. This beat only returns the body after a burst and // re-arms the intro bar. if (G.net) { G.hideO = false; // the burst took him; the walk-on returns him G.flash = { text: 'IT RISES AGAIN.', t: 1.8 }; G.interlude = { introT: 1.6 }; return; } spawnRacketZombie(); const zb = zombies.find(z => z.variant === 'r'); zb.dueled = true; // wakes racketAI, same as a fresh duel // WHO THIS ONE IS. The opponent carries a name off the chapter roster and a set of dial scalings — // pure data, read where the dials are already read (see matchFoe in config). It rides on the BODY // so killZombie can file the kill under it without asking G which opponent we were on. zb.foe = matchFoe(G.match.opp); zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS) // the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art). // 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card // of the player should last longer"). In the cup the round names itself on the third line. const cup = G.match.cup; G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam, sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1 ? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined }; // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a // game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in // racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and // matchPoint divides the same way, so the wound stages spread across the whole round instead of // being spent in game one — the body enters game two already missing what game one cost it. zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? CUP_ROUNDS[cup.round].need : 1); G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery } export function endMatch() { if (!G.match) return; G.match = null; G.interlude = false; G.matchFall = null; // a latch left armed outlives its mode zombies.length = 0; shots.length = 0; zombieDuck(false); ball.active = false; setupPoint(); } // One tennis point is settled — the ONLY entry is resolveInterlude's hook below, so every way a // point can end (bounce out, double bounce, a body hit, a whiff) funnels through the duel's own // plumbing untouched. function matchPoint(side) { const d = G.match; if (d.ending) return; const zb = zombies.find(z => z.variant === 'r'); let fellFrom = -1; if (side === 'P' && zb) { // a QUARTER of its pool per point, floored at 1 — four points walk it through every wound stage // and only the game-winning point actually fells it, so the body and the scoreboard agree all // the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.) const was = racketStage(zb); const div = 4 * ((d.cup && CUP_ROUNDS[d.cup.round].need) || 1); // the cup spreads the pool over the round zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / div)); // ONLY when the stage actually moved. A point that chips the pool without crossing a threshold // has no limb to explain, and a second of collapse for nothing is a beat the player waits out. if (racketStage(zb) !== was) fellFrom = was; } if (side === 'O') { G.hp--; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.6; aww(0.8); // ...and the umpire notices, drily. Situational, never a pool on a timer: the LOW line only // exists once the blood is nearly gone, so it lands as a warning rather than as commentary. // AFTER the score call, not under it. voice() drops any line that would start while the umpire // is still talking (one man, one microphone) — and matchPoint calls scoreKey() at 0.4 s with a // clip over a second long, so every one of these was being swallowed silently. aside(G.hp <= 2 ? 'ump_low_blood' : 'ump_wound_you2', 2.2); if (G.hp <= 0) { // netplay: my blood is bookkept only on MY phone, so my death has to be said out loud — // the other phone turns it into its win screen (netMatchWon) if (G.net && G.net.ready) netSendDead(); return killPlayer(); // eaten — the run summary, as ever } } const over = advancePoint(side); // banks the game and resets the points itself if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost(); voice(scoreKey(), 0.4); if (side === 'P') cheer(0.8, 0.3); if (fellFrom >= 0) { // A LIMB IS WORTH A REMARK. Two lines, alternating on the stage taken, so the same sentence // never lands twice in one game — a repeated aside reads as a stuck HUD, the same reason the // praise words cycle rather than roll. aside('ump_wound_opp', 2.2); // ...after the score call, see above return startFall(fellFrom); // it goes down, and gets up short of a limb } matchReady(); // next point, same opponent, both on their marks } // a clean game is how you last — and when it actually restores blood, the trainer SAYS so // (Oliver: "show wounds healed popup in zombie tennis when appropriate"). Same popup as the // duel's, so healing has one face everywhere; a full-blood win stays silent. function matchHealBeat() { const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp; G.hp += healed; if (healed > 0) { // NETPLAY: a CARD, never the modal — a modal freezes THIS world while the other phone's keeps // moving, and an unread popup would hold the opponent's next serve hostage (the popup policy's // live-court rule, applied to a court that is live on someone else's screen). if (G.net) G.card = { title: 'WOUNDS TREATED', sub1: G.hp + ' OF ' + G.hpMax + ' BACK', color: UI.pick, t: 3.0 }; else showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick, body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] }); } } // The game-winning point fells the body but no longer bursts it on the same frame: the KILL FALL // plays first (same FALL table, kill-flagged), tickMatchFall detonates it after the prone hold, // and only then runs d.afterFall — the caller's deferred finish. Regicide arrives with the body // already gone (it came apart mid-game), so there is nothing left to fall: finish immediately. function startKillFall(zb) { const d = G.match; if (!(zb && zombies.includes(zb))) { const fin = d.afterFall; d.afterFall = null; return fin(); } G.matchFall = { t: 0, from: racketStage(zb), vx: zb.vx || 0, x0: zb.x, kill: true }; if (!G.matchEarly) ball.active = false; // serving stays barred through the fall and the burst; afterFall re-arms the real beat's bar G.interlude = { introT: 4 }; G.phase = 'over'; G.overT = 4; } function matchGameWon(zb, banked = false) { const d = G.match; if (d.cup) return cupGameWon(zb, banked); d.ending = true; // killZombie in the burst re-enters resolveInterlude // REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not // on a game point) — bank the game by hand or the scoreboard drifts from the ladder. if (!banked) { score.games[0]++; score.pts = [0, 0]; } G.runGames++; // the ladder metric, same as every mode voice('game_player', 0.3); cheer(2.4, 0.6); // GAME is the FLASH and the next opponent's name plate is the CARD. It used to be the other way // round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew // once — and the plate is the better card anyway: it announces the win by replacing the loser. G.flash = { text: 'GAME.', t: 1.4 }; // netplay: the SAME opponent walks back on — the body across the net is one particular friend, // and handing them the roster's next name and face would break the fiction (the ladder dials the // opp number drives are AI-only and dormant here anyway) if (!G.net) d.opp++; // ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks // the game but never touches the server — that flip lives in tennis's own pointTo, which the // match bypasses (the same shape as the G.serveNum bug this file already carries a note about). score.server = other(score.server); // NETPLAY: the human body has no fall art to play, so the game-winning point bursts him where // he stands (procedural gore is the one sanctioned fake) and the mop beat runs over the gap; // matchOpponent's net branch un-hides him when the beat hands the court back. if (G.net) { explode(O.x, O.z); G.hideO = true; } // Everything past the burst waits for it — the heal card, the boy, the walk-on clock. d.afterFall = () => { matchHealBeat(); // ...and the boy comes out to deal with what four wound stages left on the far court. Deliberately // the FAR side: that is where the body has been standing and bleeding, and it keeps him out of the // near court where the player is about to serve. Between games only — see sendBoy's match note. sendBoy(0, 4.5, true); // THE MOP BEAT (Oliver: "mopboy must mop for 5 seconds and really remove some gore visibly // before the new opponent arrives"). The fresh opponent used to spawn on the same frame as the // kill, so the boy always mopped behind the next game's back. Now the court belongs to him // first: the full job allowance (a between-games sendBoy books one stain), and matchOpponent // waits on d.mopT (ticked in tickMatchFall). A court with nothing worth mopping skips the wait — // an empty beat is a stall, not a show. if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; } else d.mopT = 0.6; // Serving stays blocked for the whole beat: the match's permanent interlude carries it, and // keyPressed's introT bar sits ABOVE its over-beat re-arm, so a tap cannot serve at an empty // court. matchOpponent replaces this object and re-arms the normal 1.6 s intro when it runs. G.interlude = { introT: d.mopT + 1.6 }; // d.ending STAYS TRUE for the whole beat — the empty-court check in resolveInterlude reads // "no body + not ending" as regicide, so a stray resolve during the beat would bank a phantom // game. The walk-on tick is what re-opens play, exactly where the old code did. G.phase = 'over'; G.overT = d.mopT + 2.2; // ticks down through the beat; reset at the walk-on }; startKillFall(zb); } // THE CUP'S GAME. Mid-round: the same body plays on, wearing what the games have cost it. Round // won: the kill, the mop beat, and the next round's draw — or, past the final, the trophy. function cupGameWon(zb, banked = false) { const d = G.match, cup = d.cup, R = CUP_ROUNDS[cup.round]; d.ending = true; if (!banked) { score.games[0]++; score.pts = [0, 0]; } G.runGames++; voice('game_player', 0.3); cheer(2.4, 0.6); cup.g++; if (cup.g < R.need) { // the round is still alive: no kill, no mop beat — the body keeps its wounds and its feet matchHealBeat(); G.flash = { text: 'GAME.', t: 1.4 }; G.card = { title: R.name, sub1: 'YOU LEAD ' + cup.g + ' OF ' + R.need + ' — SAME BODY, FEWER PIECES', color: UI.pick, t: 3.0 }; score.server = other(score.server); d.ending = false; matchReady(); G.phase = 'over'; G.overT = 2.2; return; } // the round is won: the corpse goes down, and THEN comes apart (the kill fall, as the deathmatch) d.afterFall = () => { matchHealBeat(); cup.round++; cup.g = 0; if (cup.round >= CUP_ROUNDS.length) return cupWon(); G.flash = { text: R.name + ' — YOURS.', t: 1.8 }; // the mop beat, exactly as the deathmatch runs it: the boy owns the court, then the next draw sendBoy(0, 4.5, true); if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; } else d.mopT = 0.6; G.interlude = { introT: d.mopT + 1.6 }; cupDraw(); // the next round's rung -> d.opp score.server = other(score.server); G.phase = 'over'; G.overT = d.mopT + 2.2; // d.ending stays true until the walk-on tick }; startKillFall(zb); } // CHAMPION OF THE DEAD CLUB — the run's other ending, on the classic win's whole shape (a held // summary over a still court, then the gated restart). Banked through the same endRun as every // match run, not dead: the stats screen's deaths column keeps meaning what it means. function cupWon() { const d = G.match; d.ending = false; d.mopT = 0; G.interlude = false; ball.active = false; G.flash = null; G.modal = null; G.card = null; cheer(4, 1); voice('unbelievable', 0.4); const games = score.games[0] + score.games[1]; const b = endRun(CUP_ROUNDS.length, games, G.splatT, false, MODE_MATCH); G.best = b; G.newBest = b.newBest; d.felled = CUP_ROUNDS.length; d.games = games; d.best = b.games; G.done = { won: true, t: 0, cup: true }; } // NETPLAY'S WIN ENDING: the other phone said 'dead' — their player bled out, so this side is the // last one standing. The classic win's whole shape (held summary, gated tap), banked NOT-dead // through the same endRun as every match run; felled = the games that actually burst the body. export function netMatchWon() { if (!G.match) return; const d = G.match; d.ending = false; d.mopT = 0; G.interlude = false; G.matchFall = null; ball.active = false; G.flash = null; G.modal = null; G.card = null; cheer(4, 1); voice('unbelievable', 0.4); const games = score.games[0] + score.games[1]; const b = endRun(score.games[0], games, G.splatT, false, MODE_MATCH); G.best = b; G.newBest = b.newBest; d.felled = score.games[0]; d.games = games; d.best = b.games; G.done = { won: true, t: 0 }; } function matchGameLost() { // Reachable only through deuce (four straight points is exactly your hp) — the body keeps its // wounds, the scoreboard banks their game, and the match simply plays on. voice('game_opponent', 0.3); aww(1); score.server = other(score.server); // their game, and now their serve matchReady(); G.phase = 'over'; G.overT = 1.8; } export function resolveInterlude(side) { if (!G.interlude) return; // THE MATCH HOOK: a settled point feeds the game score instead of ending the interlude — the // interlude is permanent while a match runs (it is what keeps the serving tennis-like and the // spawn director quiet). Regicide arrives here too, as a 'P' with the body already gone. if (G.match) { // a rally resolved at its DECIDED moment (the early fall) is spent: the ball that kept rolling // for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here. // G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs. if (G.match.decided && !G.matchEarly) { ball.active = false; return; } // netplay (death match): this phone judged the live flight — the verdict crosses the wire // before it is applied, exactly as pointTo does for classic. netJudging() is false while a // remote verdict is being applied, which is what stops the echo. if (netJudging()) netSendPoint(side, null); // the empty-court check reads "no body = regicide" — meaningless in netplay, where the court // NEVER carries a body (the friend is the living O) and every point would read as a game if (!G.net && !zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null); return matchPoint(side); } 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); aside('ump_trainer', 0.2); // benched with the rest of Daniel's personality — see aside() 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(); } } // ---- NETPLAY: 1v1 classic tennis against another phone. The wire, the protocol and the judging // rule all live in src/net.js; ONLY the court ownership lives here, where the contract can be // enforced — same shape as every mode above. It runs in G.mode 'tennis' with G.classic set (the // caller does that via start('classic')), so the whole splatter schedule is structurally inert; // the disarm lines below are the same belt the drill and the bench wear anyway. export function startNetVs() { if (G.net || G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false; G.net = { ready: false, dead: false, side: null, code: null, opp: null }; G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false; G.changeover = false; G.clearing = false; G.afterClear = null; G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null; zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; ball.active = false; setupPoint(); return true; } export function endNetVs() { if (!G.net) return; netClose(); G.net = null; ball.active = false; setupPoint(); } // THE BREATHER BEFORE THE WAVE. The horde used to begin on the same frame as its own announcement, // so the flash was already gone by the time the crowd was on you — "the horde is not announced // anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock // counts it down under the flash), and startHorde still owns the transition itself. export function warnHorde() { if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same court-ownership contract as the rest G.hordeWarn = TUNE.hordeWarnT; G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT }; ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT; return true; } // the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you. // Returns false when another mode owns the court — the caller re-arms the latch rather than // silently eating the wave the set paid for. export function startHorde() { if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same contract as startDuel: one mode owns the court 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 }; // re-raised so the warning's line carries into the wave return true; }