Oliver, three refinements in one breath: the frame PROGRESSION quicker
(drop 0.06+0.25 -> 0.05+0.15 — against a live rolling ball the slower drop
read as mush), the prone hold KEEPS its bench-tuned 2 s ('the lying itself
can stay that long'), and the rise row is cut ('the opponent respawns
anyway') — done() re-marks the body in its wound skin regardless, so rising
first was a respawn with extra steps. The rise frames stay in the sheets
for the bench. Wound-beat test updated to the new contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
722 lines
46 KiB
JavaScript
722 lines
46 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 } 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
|
|
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) 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<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 — 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;
|
|
// 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;
|
|
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).
|
|
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;
|
|
// Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real
|
|
// number, so the modal can never drift from the pool it describes.
|
|
showModal({ title: 'DEATH MATCH', color: UI.hot,
|
|
body: ['WIN THE POINT — WOUND THE ZOMBIE',
|
|
'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// 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;
|
|
if (G.match) G.match.decided = false; // the next rally may be decided early again
|
|
setupPoint();
|
|
const rz = zombies.find(z => z.variant === 'r');
|
|
// THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline
|
|
// when it is the one serving. setupPoint has just placed O — the invisible tennis body the serve
|
|
// machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for
|
|
// the mark, which is what stops the picture and the ballistics disagreeing about where it stands.
|
|
if (rz) {
|
|
rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0;
|
|
rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3;
|
|
rz.showFrame = null; rz.serveHold = 0;
|
|
}
|
|
}
|
|
|
|
// ---- THE FALL. A point won knocks the body DOWN and it drags itself back up MINUS THE LIMB.
|
|
//
|
|
// It exists to explain a thing the mode was doing silently: the wound stage POPPED between points,
|
|
// so an arm simply stopped being there and nothing said why. Now the three collapse frames are the
|
|
// body it STILL WAS and the rise is the body it has BECOME — the order is the whole beat, and it is
|
|
// what makes the missing arm read as something the point did rather than a sprite swap.
|
|
//
|
|
// On its OWN clock, deliberately, and not on the 'over' beat it also sets. Any beat that moves the
|
|
// phase underneath a latch strands it forever — that is the mgT lock this file already carries a
|
|
// section about, and the horde's warning is on its own clock for the same reason. The 'over' beat is
|
|
// only there to keep the court quiet, and it is set to outlive the fall so it cannot expire into
|
|
// setupPoint() mid-collapse and hand the player a serve over a body that is still on the ground.
|
|
// fall3 is the one that HOLDS: "the opponent should fall and then lie there for a quick moment, the
|
|
// player stands up too quick." A corpse that touches the ground and is instantly upright reads as a
|
|
// stumble; the beat only lands if it is down long enough to look beaten.
|
|
// "fall quicker. lie there for 2s then stand up." The collapse is now a drop — two frames in a fifth
|
|
// of a second — and the time is spent on the floor, where it reads.
|
|
// EXPORTED AND MUTABLE, so the sprite bench's FALL button can be tuned against the art while it
|
|
// plays — refining these four numbers IS the job that button exists for, and a bench that could not
|
|
// reach them would only prove the beat runs, not that it feels right.
|
|
// TUNED IN #bench WITH THE FALL BUTTON, watching it against the art rather than reasoning about it.
|
|
// fall1 and fall2 are deliberately the SAME PACE (0.87 and 0.88 m/s of slide at a 1.54 m/s entry), so
|
|
// the collapse reads as one continuous movement decelerating into the ground; the 0.07 s fall2 it
|
|
// replaced gave the two drawn frames ~4 display frames each and read as a cut, and a 0.40 s one split
|
|
// them 0.80 against 0.56 m/s, which is a visible hitch between two poses of the same drop.
|
|
// 2026-08-13, after the early-fall retiming: "the progression of frames in the fall must be
|
|
// quicker... we must remove the rise frame, the opponent respawns anyway... the lying itself can
|
|
// stay that long." So: the DROP tightened (0.06+0.25 -> 0.05+0.15 — the collapse now plays
|
|
// against a live rolling ball, and the slower drop tuned for a dead court read as mush next to
|
|
// it), the 2 s prone hold KEEPS its bench-tuned length, and the rise row is CUT: done() puts the
|
|
// body on its mark in its wound skin anyway, and rising just to be teleported read as a respawn
|
|
// with extra steps. The z_*rise frames stay in the sheets/preload — the bench can still show them.
|
|
export const FALL = [['fall1', 0.05], ['fall2', 0.15], ['fall3', 2.0]];
|
|
// Read LIVE by startFall, because a snapshot goes stale the moment the table is tuned and the match's
|
|
// 'over' beat would then expire mid-collapse — the exact bug the comment above is about.
|
|
export const fallLen = () => FALL.reduce((s, f) => s + f[1], 0);
|
|
export const FALL_LEN = fallLen(); // the load-time value test.js imports as a loop bound
|
|
|
|
function startFall(from) {
|
|
const fz = zombies.find(z => z.variant === 'r');
|
|
G.matchFall = { t: 0, from, vx: (fz && fz.vx) || 0, x0: (fz && fz.x) || 0 };
|
|
// an EARLY-decided point keeps its ball: the whole reason the fall starts now is so the ball
|
|
// visibly rolls past the collapsing body (gameplay's matchOutOfReach call). The rally cannot
|
|
// resolve twice — the d.decided latch swallows every later resolution of it.
|
|
if (!G.matchEarly) ball.active = false;
|
|
G.phase = 'over'; G.overT = fallLen() + 0.6; // outlives the fall on purpose — see above
|
|
}
|
|
|
|
// Ticked from update(), beside the horde's warning, for the same reason it is. It also drives the
|
|
// OPPONENT'S SERVE POSES, because both are the same job — naming the frame while the body is doing
|
|
// something the walk cycle has no picture for.
|
|
export function tickMatchFall(dt) {
|
|
// THE MOP BEAT runs here, on the same clock discipline as the fall: its own timer, never the
|
|
// 'over' beat (see the section note above). When the boy's time is up the next opponent walks
|
|
// on with its own intro card, exactly as it used to on the kill frame.
|
|
const d = G.match;
|
|
if (d && d.mopT > 0 && (d.mopT -= dt) <= 0) {
|
|
// Time is up — but THE COURT IS HIS UNTIL HE LEAVES IT (Oliver: "no playing as long as the
|
|
// mopboy is on court"). He is ordered off (jobs zeroed, the half-pushed stain dropped) and
|
|
// the walk-on holds until he is clear of the playing area; the 6 s cap is a belt on top of
|
|
// the braces — nothing should ever need it, but a stalled beat must not stall the match.
|
|
const b = G.boy;
|
|
if (b && b.mop && Math.abs(b.x) < 6.5 && (d.mopHold = (d.mopHold || 0) + dt) < 6) {
|
|
b.jobs = 0; b.tx = undefined; b.tz = undefined; b.mopT = 0;
|
|
if (G.interlude && G.interlude.introT < 1) G.interlude.introT = 1; // serving stays barred
|
|
d.mopT = 0.001; // the beat re-checks next tick
|
|
} else {
|
|
d.mopT = 0; d.mopHold = 0;
|
|
matchOpponent();
|
|
d.ending = false; // play re-opens only now that a body is on court
|
|
matchReady();
|
|
G.phase = 'over'; G.overT = 2.2; // matchReady's setupPoint re-arms pretoss; keep the old beat
|
|
}
|
|
}
|
|
if (!G.matchFall) return tickMatchServe(dt);
|
|
const f = G.matchFall;
|
|
const zb = zombies.find(z => z.variant === 'r');
|
|
// THE BENCH OWNS THE COURT WHEN IT IS OPEN, so neither exit hands a point back: matchReady() runs
|
|
// setupPoint() and re-places the body on its receiving mark, which is a MATCH doing match things
|
|
// inside an art test. Same one-mode-owns-the-court rule the start* functions all obey.
|
|
const done = () => { G.matchFall = null; if (zb) zb.showFrame = null; if (!G.bench) matchReady(); };
|
|
if (!zb) { G.matchFall = null; if (!G.bench) matchReady(); return; } // 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) 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() {
|
|
spawnRacketZombie();
|
|
const zb = zombies.find(z => z.variant === 'r');
|
|
zb.dueled = true; // wakes racketAI, same as a fresh duel
|
|
// WHO THIS ONE IS. The opponent carries a name off the chapter roster and a set of dial scalings —
|
|
// pure data, read where the dials are already read (see matchFoe in config). It rides on the BODY
|
|
// so killZombie can file the kill under it without asking G which opponent we were on.
|
|
zb.foe = matchFoe(G.match.opp);
|
|
zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS)
|
|
// the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art).
|
|
// 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card
|
|
// of the player should last longer")
|
|
G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam };
|
|
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
|
|
// 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.
|
|
const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp;
|
|
G.hp += healed;
|
|
if (healed > 0)
|
|
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
|
|
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
|
|
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);
|
|
// 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.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);
|
|
// 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 below 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
|
|
}
|
|
|
|
function matchGameLost() {
|
|
// Reachable only through deuce (four straight points is exactly your hp) — the body keeps its
|
|
// wounds, the scoreboard banks their game, and the match simply plays on.
|
|
voice('game_opponent', 0.3); aww(1);
|
|
score.server = other(score.server); // their game, and now their serve
|
|
matchReady();
|
|
G.phase = 'over'; G.overT = 1.8;
|
|
}
|
|
|
|
export function resolveInterlude(side) {
|
|
if (!G.interlude) return;
|
|
// THE MATCH HOOK: a settled point feeds the game score instead of ending the interlude — the
|
|
// interlude is permanent while a match runs (it is what keeps the serving tennis-like and the
|
|
// spawn director quiet). Regicide arrives here too, as a 'P' with the body already gone.
|
|
if (G.match) {
|
|
// a rally resolved at its DECIDED moment (the early fall) is spent: the ball that kept rolling
|
|
// for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here
|
|
if (G.match.decided) { ball.active = false; return; }
|
|
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);
|
|
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();
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|