The code moves into src/: index, server and the test harness keep the root
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c122745390
commit
1f636530b9
16 files changed
+16
-13
No files matched your search
+217
@@ -0,0 +1,217 @@
|
||||
// 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, COURT, HOME } from './config.js';
|
||||
import { G, P, ball, score, setupPoint, sendBoy } from './state.js';
|
||||
import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie } from './zombies.js';
|
||||
import { cheer, aside, endBurst, deliverTin, showModal, logSwing } from './gameplay.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) 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) return;
|
||||
G.bossFight = chapter;
|
||||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
|
||||
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
|
||||
ball.active = false; setupPoint();
|
||||
spawnChapterBoss(chapter);
|
||||
zombieDuck(true); // the music drops out for it
|
||||
voice('ump_boss_arrives', 0, 0.9); voice('sudden_death', 2.6, 0.85);
|
||||
const art = bossArt(chapter);
|
||||
G.card = { title: art.name, color: UI.bad, t: TUNE.setCardT };
|
||||
G.flash = null;
|
||||
// (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 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.aim = null; endBurst(); setupPoint();
|
||||
stuckT = 0; stuckSig = '';
|
||||
}
|
||||
|
||||
export function resolveInterlude(side) {
|
||||
if (!G.interlude) return;
|
||||
G.interlude = false;
|
||||
zombieDuck(false); // duel over — bring the music back up
|
||||
ball.active = false; G.phase = 'over'; G.overT = 2.2;
|
||||
if (side === 'P') {
|
||||
cheer(3, 0.7);
|
||||
deliverTin(); // win the point, win the next tin
|
||||
// ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
|
||||
// the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
|
||||
// gets its OWN popup (queued behind the weapon), with the red cross over it: folded in as a line
|
||||
// of body text under a weapon icon it read as a footnote to the tin rather than as the prize.
|
||||
if (G.hp < G.hpMax) {
|
||||
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
|
||||
voice('ump_trainer', 0.2, 0.95);
|
||||
showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick,
|
||||
body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] });
|
||||
}
|
||||
} else {
|
||||
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
|
||||
score.games[1]++; G.runGames++; // it goes on the scoreboard
|
||||
if (score.games[1] >= 6) { G.sets[1]++; score.games = [0, 0]; }
|
||||
G.flash = { text: 'GAME. THEM.', t: 1.6 };
|
||||
for (let k = 0; k < 5; k++) // and the dead lose their patience: an instant surge
|
||||
spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random() * 2, Math.random() < 0.5 ? 'a' : 'b');
|
||||
}
|
||||
if (G.afterDuel) { // a scheduled duel interrupted a game beat — hand it back now that it's settled
|
||||
G.afterClear = G.afterDuel; G.afterDuel = null;
|
||||
proceedAfterClear();
|
||||
}
|
||||
}
|
||||
|
||||
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you
|
||||
export function startHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight) return; // 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 };
|
||||
}
|
||||
Reference in new issue
Block a user