// 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 } from './config.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'; // 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) 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) return; G.bossFight = chapter; zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage G.horde = null; G.mgT = 0; endBurst(); G.clearing = false; ball.active = false; setupPoint(); spawnChapterBoss(chapter); zombieDuck(true); // the music drops out for it voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85); const art = 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) 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 export function startBench() { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) 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; 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 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 — one today, one row in config.ZFAMS per variant after that else if (code === 'ArrowUp' || code === 'KeyW') b.fam = (b.fam + ZFAMS.length - 1) % ZFAMS.length; else if (code === 'ArrowDown' || code === 'KeyS') b.fam = (b.fam + 1) % ZFAMS.length; 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 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.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; } } // ---- 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). export function startMatch() { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false; G.match = { opp: 1, ending: false }; 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 (!G.seen.match) { G.seen.match = true; showModal({ title: 'EXHIBITION MATCH', color: UI.hot, body: ['WIN THE GAME — EVERY POINT WOUNDS IT', 'ITS POINTS DRAW YOUR BLOOD'] }); } return true; } // 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. function matchReady() { ball.active = false; G.serveNum = 1; 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. const FALL = [['fall1', 0.05], ['fall2', 0.07], ['fall3', 2.0], ['rise', 0.30]]; export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0); function startFall(from) { G.matchFall = { t: 0, from }; ball.active = false; G.phase = 'over'; G.overT = FALL_LEN + 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) { if (!G.matchFall) return tickMatchServe(dt); const f = G.matchFall; const zb = zombies.find(z => z.variant === 'r'); if (!zb) { G.matchFall = null; return matchReady(); } // felled by something else mid-fall f.t += dt; let acc = 0, cur = null; for (const [suf, len] of FALL) { acc += len; if (f.t < acc) { cur = suf; break; } } if (!cur) { // back on its feet: hand the point back G.matchFall = null; zb.showFrame = null; return matchReady(); } // ...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. 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() { 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) G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 2.4 }; zb.hp = zb.hpMax = TUNE.matchOppHp; // a FLAT pool: with regicide gone the body is pure // scoreboard (a quarter per point), so a deeper pool would // change nothing — the ladder escalates in racketAI instead // (matchSpeedPer / matchFlubKeep) 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); zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / 4)); // 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) 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 } function matchGameWon(zb, banked = false) { const d = G.match; d.ending = true; // killZombie below re-enters resolveInterlude if (zb && zombies.includes(zb)) explode(zb.x, zb.z); // the game-winning point IS the kill // 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 G.hp = Math.min(G.hpMax, G.hp + TUNE.matchHeal); // a clean game is how you last 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 }; // ...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); 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); matchOpponent(); // fresh body, its own intro beat d.ending = false; matchReady(); G.phase = 'over'; G.overT = 2.2; } 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) { if (!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); voice('ump_trainer', 0.2, 0.95); showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick, body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] }); } } else { voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel score.games[1]++; G.runGames++; // it goes on the scoreboard if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; } G.flash = { text: 'GAME. THEM.', t: 1.6 }; for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b'); } if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); } } // 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) 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) 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; }