1093 lines
69 KiB
JavaScript
1093 lines
69 KiB
JavaScript
// 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, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js';
|
|
import { endRun, MODE_MATCH } from './stats.js';
|
|
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } from './profile.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 { recordEvent } from './recorder.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.serveNum = 1; // ...on a FIRST serve. A duel now faults (gameplay's onBounce), and
|
|
// nothing else resets it here — a fault in the last duel would have
|
|
// armed second-serve in the next one, which is the death loop this
|
|
// file already carries a note about, one mode over.
|
|
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'],
|
|
// both stakes named (the old card only sold the win): a lost duel is a real cost
|
|
rules: ['WIN IT AND THEY HAND YOU A WEAPON',
|
|
{ lead: 'LOSE IT AND FIVE MORE CLIMB THE FENCE', hot: true }] });
|
|
} 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<suffix> at full health,
|
|
// z_rw1<suffix> / z_rw2<suffix> 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).
|
|
// `train` (2026-08-28) is a RUNG NUMBER and turns the run into the TRAINING COURT: that one
|
|
// opponent, one game at a time, nothing banked. The ladder is parked — d.opp never advances, a
|
|
// felled body walks straight back on, and bleeding out costs a modal instead of the run. All of
|
|
// it rides the ordinary match machinery behind the d.train flag; see matchGameWon / matchPoint.
|
|
// `draw` is DEAD weight kept only for call-site arity — the cup runs easy to hard now
|
|
// (cupRoundsFor is deterministic, 2026-08-29) and nothing reads it.
|
|
export function startMatch(cup = false, draw = null, train = 0) {
|
|
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
|
|
// THE LEAGUES: the profile's league is the world (band, pace tier, court — see PROJECT.md).
|
|
// Stamped onto G so gameplay's leaguePace() and the dials below never read the profile module.
|
|
// Netplay stays a league-1 world. Training still stamps league 1 because it promotes nobody,
|
|
// but its selected opponent supplies the real territory pace through gameplay.leaguePace.
|
|
G.league = G.net || train ? 1 : prof.lg;
|
|
// THE CLIMB: the arcade opens at the very BOTTOM of the ladder and runs the whole earned club,
|
|
// clamping at your league's top (matchGameWon) — the marathon whose length the promotions buy.
|
|
// The tempo shifts per band border (gameplay.leaguePace reads the rung's own league).
|
|
G.match = { opp: train || 1, ending: false, train: !!train, felled: 0 };
|
|
// 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.cupRoundsFor.
|
|
if (cup) { G.match.cup = { round: 0, g: 0, rounds: cupRoundsFor(G.league) }; 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];
|
|
// The mode's rules modal fires BEFORE matchOpponent, so it lands FIRST and the opponent's own
|
|
// walk-on modal queues behind it (showModal's modalQ keeps call order) — rules, then the body.
|
|
if (train && !G.seen.train) {
|
|
G.seen.train = true;
|
|
showModal({ title: 'THE TRAINING COURT', color: UI.hot,
|
|
body: ['ONE BODY, ONE GAME AT A TIME',
|
|
'FELL IT — OR BLEED OUT — AND IT COMES BACK', 'NO RUN IS BANKED — REPS PAY QUARTER XP'],
|
|
rules: ['FELL IT OR BLEED OUT — IT COMES BACK', 'NOTHING IS BANKED · REPS PAY ¼ XP'] });
|
|
} else if (cup && !G.seen.cup) {
|
|
G.seen.cup = true;
|
|
showModal({ title: 'THE TOURNAMENT', color: UI.hot, strap: cupFixture(G.league, prof.ch).sub,
|
|
// plain words (Oliver: "dead hierarchy" said nothing) — what you play, what winning buys
|
|
body: ['YOUR LEAGUE, ROUND BY ROUND — WIN IT ALL, MOVE UP A LEAGUE',
|
|
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
|
rules: ['WIN IT ALL — MOVE UP A LEAGUE',
|
|
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
|
|
} else if (!cup && !train && !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. The card wears the mode's
|
|
// OWN name: the solo arcade is THE CLIMB now — its card still saying DEATH MATCH was a
|
|
// leftover of the era when it was one (2026-08-29 copy audit). Netplay keeps the old title,
|
|
// because there it is literally the mode.
|
|
showModal(G.net
|
|
? { title: 'DEATH MATCH', color: UI.hot,
|
|
body: ['WIN THE POINT — WOUND YOUR FRIEND',
|
|
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
|
rules: ['WIN THE POINT — WOUND YOUR FRIEND',
|
|
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] }
|
|
: { title: 'THE CLIMB', color: UI.hot,
|
|
body: ['THE WHOLE CLUB, BOTTOM UP — ONE GAME PER BODY',
|
|
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'],
|
|
rules: ['ONE GAME PER BODY — HOW FAR CAN YOU GET?',
|
|
{ lead: 'POINTS DRAW BLOOD', hot: true, pips: TUNE.matchPlayerHp }] });
|
|
}
|
|
recordEvent('run_start');
|
|
matchOpponent();
|
|
matchReady();
|
|
zombieDuck(true);
|
|
voice('play_resume', 0.2);
|
|
return true;
|
|
}
|
|
|
|
// The round's opponent. No draw and no roll any more: the rounds ARE the band, ascending, so
|
|
// this simply reads the round's one position off (cupRoundsFor carries the order). d.opp carries
|
|
// it so matchFoe, the ladder speed dials and the felled arithmetic stay cup-unaware.
|
|
function cupDraw() {
|
|
const cup = G.match.cup;
|
|
G.match.opp = cup.rung = cup.rounds[cup.round].band[0];
|
|
}
|
|
|
|
// 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
|
|
// ...and THE READ's one-ball states die with the point: a lean only lasts the flight it answered
|
|
if (G.match) G.match.surprised = G.match.expected = false;
|
|
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) placeReceiver(rz);
|
|
}
|
|
|
|
// WHERE A RETURNER ACTUALLY STANDS. The tennis mark is 1.3 m off the centre line, and from there he
|
|
// bisects nothing: the T is one step away and the corner of the box is four. Measured headless, a
|
|
// flush serve with full sway into that corner was returned 0 times out of 20 at EVERY rung of the
|
|
// ladder — Oliver: "the first ten zombies can be killed the moment you serve". He now stands out on
|
|
// the bisector of the two serves he has to cover, which is what a real receiver does, and O takes
|
|
// the same mark so the picture and the ballistics still agree.
|
|
// Split out of matchReady because a SECOND SERVE needs the same mark without matchReady's rest:
|
|
// that call resets G.serveNum, which is the one thing a fault has just spent (see fault()).
|
|
export function placeReceiver(rz = zombies.find(z => z.variant === 'r')) {
|
|
if (!rz) return;
|
|
if (score.server === 'P') O.x = Math.sign(O.x || 1) * TUNE.matchRecvX;
|
|
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.winT > 0 && (d.winT -= dt) <= 0) { d.winT = 0; return netMatchDone(); }
|
|
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 IS A MODAL NOW, not a timed card (2026-08-28, Oliver: "als richtiges Modal, das
|
|
// man wegtappen muss — you can actually read it without time pressure, and tap it away fast when
|
|
// you have read it a hundred times"). The walk-on is a guaranteed-calm court (the mop beat has
|
|
// just ended, the intro bar is armed), so the popup policy allows it; update()'s freeze holds the
|
|
// 1.6 s intro beat until the tap, and the head wears the same z_<fam>head crop the card wore.
|
|
// In the cup the round names itself on the second line.
|
|
const cup = G.match.cup;
|
|
// THE WALK-ON wears its own zoned layout (render.drawFoeModal — Oliver: the generic line
|
|
// stack was "a wall of text"). `foe` drives the drawing; title/body still carry the same
|
|
// words for the suite and any legacy reader. The level pair is also the cup's readiness measure:
|
|
// gameplay.peerK turns closing this gap into extra legs and timing, without weakening the body.
|
|
const sub = cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1
|
|
? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')]
|
|
// the top of the band walking BACK on is the arcade's natural "what now?" moment — the
|
|
// intro answers it in place (THE LEAGUES: the cup is the only promotion)
|
|
: G.match.again ? ['NOBODY STRONGER IS LEFT', 'THE CUP IS THE WAY UP'] : [];
|
|
const lvl = foeLevel(G.match.opp), mine = levelAt(prof.xp);
|
|
const gap = lvl - mine;
|
|
const peer = gap > 5 ? gap + ' LEVELS ABOVE YOU' : gap > 0 ? 'ALMOST EVEN'
|
|
: gap === 0 ? 'EVENLY MATCHED' : 'YOU HAVE THE EDGE';
|
|
showModal({ title: zb.foe.name, color: UI.hot,
|
|
body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub),
|
|
foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line,
|
|
lvl, mine, peer, sub } });
|
|
// ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config)
|
|
G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false;
|
|
// 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);
|
|
recordEvent('opponent', { traits: { ...zb.foe } });
|
|
G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery
|
|
}
|
|
|
|
export function endMatch() {
|
|
if (!G.match) return;
|
|
recordEvent('run_end', { outcome: 'ended' });
|
|
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 && d.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) {
|
|
// THE TRAINING COURT HAS NO DEATH IN IT. Bleeding out costs a modal and a fresh pool — the
|
|
// point still goes to the body (advancePoint below), so a game you were losing stays lost.
|
|
if (d.train) {
|
|
G.hp = G.hpMax;
|
|
showModal({ title: 'IT BLED YOU OUT', color: UI.bad,
|
|
body: ['THE TRAINER MOPS YOU UP — GO AGAIN', 'NOTHING IS BANKED HERE'] });
|
|
} else {
|
|
// 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]; }
|
|
if (!d.train) G.runGames++; // the ladder metric — training banks nothing
|
|
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). Training parks the ladder the same
|
|
// way: the rung you picked is the rung that walks back on, as many times as you want it.
|
|
if (!G.net && !d.train) {
|
|
d.felled = (d.felled || 0) + 1; // bodies actually felled (the board's number)
|
|
// ...and the ladder climbs only inside the league's band: past its top rung the league has
|
|
// nobody stronger, so the top body walks back on (THE LEAGUES — the cup is the way up).
|
|
// `d.again` marks exactly that moment — the felled body WAS the top — so its walk-on modal
|
|
// can answer the "what now?" the repeat would otherwise pose (see matchOpponent).
|
|
const top = leagueOf(G.league).band[1];
|
|
d.again = d.opp >= top;
|
|
d.opp = Math.min(d.opp + 1, top);
|
|
}
|
|
// 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.
|
|
// NETPLAY DEATH MATCH: A GAME IS NOT A DEATH, AND ONLY BLOOD IS (Oliver, 2026-08-22: "each player
|
|
// has 8 blood points but the player explodes for the winner already at 0:40 — the winner sees the
|
|
// zombie explode but the player on the other side is not dead yet. this needs to be aligned").
|
|
// Two clocks were running: a GAME is four points, a life is eight, so the body burst on this
|
|
// phone while the friend was still standing on theirs with half a pool left. The burst moves to
|
|
// the only event both phones agree is death — their blood running out, said out loud over the
|
|
// wire (netSendDead -> netMatchWon). A won game keeps everything it is worth: the flash, the
|
|
// serve, and the heal it pays. Solo is untouched — there the body IS the scoreboard.
|
|
if (G.net) {
|
|
matchHealBeat();
|
|
d.ending = false; // nothing is walking on: play resumes here
|
|
matchReady();
|
|
G.interlude = { introT: 1.2 }; // a breath before the next serve, as ever
|
|
G.phase = 'over'; G.overT = 2.2;
|
|
return;
|
|
}
|
|
// Everything past the burst waits for it — the heal card, the boy, the walk-on clock.
|
|
d.afterFall = () => {
|
|
// training resets the blood WITHOUT the WOUNDS TREATED ceremony — every game starts whole,
|
|
// and a popup between every single rep is exactly the tap-tax the mode exists to avoid
|
|
if (d.train) G.hp = G.hpMax; else 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)
|
|
award('round');
|
|
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);
|
|
award('cup');
|
|
// THE PROMOTION (the leagues): the cup of your CURRENT league is the gate up. Winning it moves
|
|
// the profile, unlocks the next league's court for every mode, and parks a ceremony that
|
|
// main.maybeRestart plays once the summary is tapped away. Winning league 5 crowns the profile
|
|
// instead (prof.ch) — the summary below already says CHAMPION OF THE DEAD CLUB.
|
|
if (!d.train && G.league === prof.lg) {
|
|
// THE INSCRIPTION (the cup board): the level you were when you took it, written before the
|
|
// promotion moves anything — it is the level that WON the cup, not the one you walk on with.
|
|
const at = levelAt(prof.xp);
|
|
recordCupWin(G.league, at);
|
|
if (prof.lg < 5) { prof.lg++; setPromo({ to: prof.lg, took: G.league, at }); }
|
|
else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true, took: G.league, at }); }
|
|
profSave(); profPush();
|
|
}
|
|
if (!d.train) recordCup(G.league, d.cup.rounds.length); // the full record: you took the final
|
|
const games = score.games[0] + score.games[1];
|
|
const b = endRun(d.cup.rounds.length, games, G.splatT, false, MODE_MATCH);
|
|
G.best = b; G.newBest = b.newBest;
|
|
d.felled = d.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.
|
|
// THE FRIEND'S BLOOD RAN OUT — the one event that ends a death match, and now the only one that
|
|
// bursts him. It arrives as a message from their phone (they bookkeep their own hp), which is why
|
|
// this is the honest place for the burst: both sides are agreeing about the same death.
|
|
export function netMatchWon() {
|
|
if (!G.match || G.match.winT > 0) return; // already ending
|
|
const d = G.match;
|
|
d.ending = false; d.mopT = 0;
|
|
G.matchFall = null;
|
|
ball.active = false; G.flash = null; G.modal = null; G.card = null;
|
|
explode(O.x, O.z); G.hideO = true;
|
|
cheer(4, 1); voice('unbelievable', 0.4);
|
|
// ...AND THE SUMMARY WAITS FOR THE GORE. update() freezes the whole world on G.done — no
|
|
// particles, no parts — so setting it on the same frame as the burst would hang a still frame of
|
|
// an explosion behind the trophy screen. The beat is ticked in tickMatchFall, like every other
|
|
// beat in this file that has to outlive the frame that started it.
|
|
d.winT = TUNE.netWinBurst;
|
|
G.interlude = { introT: TUNE.netWinBurst + 1 }; // nothing gets served over a body coming apart
|
|
G.phase = 'over'; G.overT = TUNE.netWinBurst + 1;
|
|
}
|
|
|
|
function netMatchDone() {
|
|
const d = G.match;
|
|
G.interlude = false;
|
|
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, reason = 'unspecified') {
|
|
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; }
|
|
if (!G.match.ending) recordEvent('point', { winner: side, reason });
|
|
// 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);
|
|
award('duelWin');
|
|
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;
|
|
}
|