From c12274539066e38335b007d8bf9dcefc4b3dd9e2 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Tue, 11 Aug 2026 13:23:27 +0200 Subject: [PATCH] =?UTF-8?q?One=20file=20owns=20the=20court:=20beats.js,=20?= =?UTF-8?q?PROJECT.md=20(n=C3=A9=20HANDOFF),=20and=20tests=20gate=20the=20?= =?UTF-8?q?build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 12 +++ Dockerfile | 9 ++ HANDOFF.md => PROJECT.md | 62 +++++++---- beats.js | 217 +++++++++++++++++++++++++++++++++++++++ config.js | 4 +- gameplay.js | 209 ++----------------------------------- index.html | 2 +- main.js | 3 +- render.js | 2 +- test.js | 64 +++++++++++- zombies.js | 9 +- 11 files changed, 362 insertions(+), 231 deletions(-) create mode 100644 CLAUDE.md rename HANDOFF.md => PROJECT.md (97%) create mode 100644 beats.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9e83be0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# Splatter Tennis — session rules + +1. **Read `PROJECT.md` first** — it is the only planning document: how Oliver's feedback is handled, + every invariant, every settled feel decision, what is open. Do not re-derive or contradict it. +2. **`node test.js` must pass before every commit.** It is also a Dockerfile build stage, so a red + suite blocks the deploy — but run it locally first. New state-machine behavior gets a test. +3. **Mode transitions live in `beats.js` only** (duel/horde/boss/changeover). One mode owns the + court; every `start*` refuses while another mode runs. A new mode starts there and refuses there. +4. **Every push deploys** (Coolify → splatter-tennis.wuwi.one). Bump `main.js?v=N` in index.html on + every push or phones keep the old build. New top-level files must be added to the Dockerfile COPY. +5. **Zero dependencies, no build step, no framework.** This is a settled decision — solve it with + what is already in the repo. diff --git a/Dockerfile b/Dockerfile index bd12467..8249428 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,11 @@ +# STAGE 1 — the test gate. `node test.js` drives the real game modules headless; if the state +# machine breaks, this stage fails, the image never builds, and the deploy never ships. The final +# stage COPYs the marker so BuildKit cannot skip this stage as unused. +FROM node:22-alpine AS test +WORKDIR /app +COPY *.js /app/ +RUN node test.js && touch /tests-passed + FROM python:3.12-alpine # Was nginx:alpine. The leaderboard needs one process that can WRITE, and nginx is not one. # Everything the nginx conf was doing here — no-cache so a deploy is picked up on a plain reload, @@ -5,6 +13,7 @@ FROM python:3.12-alpine # app — is a few lines in server.py. Standard library only: no pip, no requirements file. # ponytail: switch to hashed filenames if traffic ever matters. WORKDIR /app +COPY --from=test /tests-passed /tmp/tests-passed COPY index.html manifest.webmanifest *.js server.py /app/ COPY assets /app/assets # THE SCORES LIVE HERE AND THIS MUST BE A PERSISTENT VOLUME. Without one the json sits in the diff --git a/HANDOFF.md b/PROJECT.md similarity index 97% rename from HANDOFF.md rename to PROJECT.md index d3376e1..e863dd5 100644 --- a/HANDOFF.md +++ b/PROJECT.md @@ -1,11 +1,16 @@ -# HANDOFF — Splatter Tennis +# PROJECT — Splatter Tennis -**This file is the only planning document.** `PLAN.md` and `POLISH-PLAN.md` are deleted — +**This file is the only planning document** (renamed from `HANDOFF.md`, 2026-08-11 — it was never a +handoff, it is the project's living notebook). `PLAN.md` and `POLISH-PLAN.md` are deleted — everything they specified is shipped, and what still matters from them is recorded below. For the long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the detail of any individual change, read the git log. -Last updated 2026-08-11 (the third freeze: court-ownership guards + `node test.js`). **Start with "How to work on this", "OPEN — leftovers" and "LATER — +**`node test.js` must pass before any commit** — it drives the real game modules headless and pins +the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs +it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way. + +Last updated 2026-08-11 (beats.js extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER — shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback should be handled, the second is everything known to be outstanding in the game, the third is the demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed @@ -49,15 +54,19 @@ the primary target. ## Module map (all in repo root) `config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object + P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` + -transition + `splatPoint` + ball-boy routines · `zombies.js` zombie/gore/particle state + -`updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick · -`audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel · -`main.js` boot, loop, scoreboard test-mode taps, `window.T` test surface (the umpire-tap cheat is -removed — the testteam found it; `T.forceZombies()` is the console replacement). -THREE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay, -and state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for). -All three are safe for the same reason: the imported binding is only ever touched inside a function at -runtime, never during module evaluation. +transition + `splatPoint` + ball-boy routines · `beats.js` **the beat machinery: every mode +transition (duel/horde/boss/changeover/`proceedAfterClear`) — ONE MODE OWNS THE COURT, and each +`start*` enforces that itself; a new mode starts there and refuses there** · `zombies.js` +zombie/gore/particle state + `updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · +`input.js` keys/touch/flick · `audio.js` sfx + recorded umpire (`assets/audio/*`, macOS +`say -v Daniel`) · `panel.js` the T panel · `main.js` boot, loop, scoreboard test-mode taps, +`window.T` test surface (the umpire-tap cheat is removed — the testteam found it; +`T.forceZombies()` is the console replacement) · `test.js` the headless regression suite +(`node test.js`, also a Dockerfile build stage — a red suite cannot deploy). +FIVE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay, +state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for), +beats↔gameplay and beats↔zombies. All are safe for the same reason: the imported binding is only +ever touched inside a function at runtime, never during module evaluation. ## How the game flows Tennis → win a game → **transition** (`G.mode='transition'`, timeline on `G.transT`) → **splatter**. @@ -271,12 +280,6 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu Sizing it the other way round (compute a scale, then set explicit px) laid it out against whatever `innerHeight` reported at load, before iOS Safari's toolbars had settled, and it stayed a strip short of the bottom forever after. `100dvh` is what tracks those toolbars. -- **The swish cooldown scales with how swarmed you are** (`meleeCooldown()` in gameplay.js): - `meleeCool / (1 + (near-1) * meleeCrowd)`, floored at `meleeCoolMin`, counting bodies within - `meleeCrowdR`. 1 body 1.00 s, 4 → 0.47, 8 → 0.27, 14+ → 0.22. Oliver died on every game-20 horde - because the wave grows with `hordeGrow` while the panic button was a flat 1 s; he chose scaling the - melee over shrinking the horde. **Measure the crowd BEFORE calling `meleeSwipe`** — it kills - everything in reach, so counting after counts the survivors and the cooldown barely moves. - **The canvas is deliberately FULL-BLEED under the notch and home indicator; the HUD is what respects the safe area.** `viewport-fit=cover` is set, and the stands running to the screen edges look right — but an iPhone's clock was landing directly on the scoreboard. A hidden `#safe` probe div carries @@ -292,7 +295,7 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu Widest string checked fits 270px portrait with ~10px spare. - **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`. The schedule (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`) calls the **duel**, fired from - `proceedAfterClear()` — the one beat where the court is guaranteed empty. The game beat it + `proceedAfterClear()` (beats.js) — the one beat where the court is guaranteed empty. The game beat it interrupts is parked in `G.afterDuel` and resumed when the duel resolves. - **Anything that blocks serving.** If a "can't serve" report ever comes in, check these first — each is an early return in `keyPressed`: `G.mgT`, `G.horde`, `G.changeover`, `G.interlude.introT`, @@ -376,6 +379,27 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: beats.js, the rename, and the build gate (2026-08-11, same day) + +Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both — +the freezes were never a file-size problem, they were a shared-state interleaving problem — and +three targeted moves instead: +- **`beats.js`**: every mode transition extracted from gameplay.js into one file with one stated + rule (one mode owns the court; every `start*` enforces it itself). Pure move, zero behavior + change, verified by the suite passing untouched before and after. +- **This file renamed** `HANDOFF.md` → `PROJECT.md` and audited against the code: the duplicated + swish-cooldown invariant deduped, the stale umpire-triple-tap comment in gameplay.js corrected + (the cheat was removed when the testteam found it), all `HANDOFF` references in code comments + updated. `T.step`/`T.press`, `machEvery` 4, `bossEvery` 10 all verified still true. +- **The Dockerfile gained a test stage** (`node:22-alpine`, `RUN node test.js`): a red suite fails + the image build, so Coolify never deploys a build whose state machine is broken. The final stage + COPYs the stage's marker file so BuildKit cannot skip it. +- The suite grew five regressions mined from this file's own history: the pending-horde-onto-boss- + stage latch (7803c83), the game-30 EPIC-card clobber, the mgT cross-phase serve lock (e75b388), + the duel's payout+heal contract, and the lost-duel set banking with its surge. +- **`CLAUDE.md` added** (auto-loaded every session): the five rules that keep sessions from getting + sloppy — read this file first, run the tests, bump `?v=`, the Dockerfile COPY rule, no deps. + ## Session log: the third freeze — court ownership enforced, and the first tests (2026-08-11) Oliver hit the horde freeze a THIRD time, earlier in a run: **a sudden-death duel started inside a diff --git a/beats.js b/beats.js new file mode 100644 index 0000000..4787553 --- /dev/null +++ b/beats.js @@ -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 }; +} diff --git a/config.js b/config.js index cb4cb9e..d593bfd 100644 --- a/config.js +++ b/config.js @@ -46,7 +46,7 @@ export const TUNE = { // ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why // a 50-game run stopped changing (games 12..50 were arithmetically identical). They are all keyed // on G.runGames now and all keep moving; the ceilings that remain exist for perf or fairness, not - // because the curve ran out. See HANDOFF "The difficulty director". + // because the curve ran out. See PROJECT.md "The difficulty director". // Calibrated to TRACK the old curve through roughly game 12 and then keep going, rather than to // replace it: the first pass at this made games 1-30 easier, which is the opposite of the ask. zSpawnEvery: 3.0, // s between spawns in game 1 @@ -70,7 +70,7 @@ export const TUNE = { // for ~120 spawns where the old fixed 12 s x 2.5/s could only ever ask for 30, // and a 40-body court on a 270px-wide portrait screen is mush, not menace. // ---- PLAYER HEALTH. A SECOND, SEPARATE damage channel: only ranged attacks touch it. A shambler - // reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see HANDOFF. + // reaching you is still instant permadeath (G.mode='eaten') — that decision is settled, see PROJECT.md. // There is no HP bar and there never will be: the state shows as blood on the man. playerHp: 4, // hits from ranged attacks you can absorb before you go down hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley diff --git a/gameplay.js b/gameplay.js index 4dd1517..74a1c6c 100644 --- a/gameplay.js +++ b/gameplay.js @@ -6,6 +6,7 @@ import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zom import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; +import { proceedAfterClear, startDuel, startHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. @@ -529,48 +530,8 @@ export function splatPoint(side, quiet = false) { } } -// 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 -} +// (proceedAfterClear and every start*/end* mode transition live in beats.js — one file owns who +// has the court. splatPoint above only ARMS the schedule latches; beats.js decides when they fire.) // ---- the courtside rack. RACK_MAX + 1 slots, and only slot 0 is fixed: it is BALL (infinite), or // an overflow weapon riding it temporarily. Slots 1..RACK_MAX are what you have picked up, in order. @@ -708,149 +669,8 @@ function tickTestMode(dt) { stockTestRack(); // no barrels: the rack is already full of everything they would hand you, and } // one rolling across the sandbox was just something else to look at -// ---- 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 = ''; -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(); - } -} +// (startDuel, startBossFight/endBossFight, the deadlock watchdog and resolveInterlude live in +// beats.js — see its header for the one-owner rule.) // retire the splatter ball; a running changeover's long beat must not be clobbered short export function spentBall() { @@ -884,23 +704,8 @@ export function dodge(dir) { const xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z))); const xLimit = () => Math.max(xVisible(), COURT.W / 2); -// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you -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 }; -} +// (startHorde lives in beats.js) -// hidden shortcut: tapping the umpire three times summons the horror early // Back to a fresh run WITHOUT reloading the page. The reload was the honest reset, but it also tore // down the AudioContext, so the menu theme restarted from silence every time you died — and the menu // is supposed to pick up the track that was already playing over the death summary. Every mutable @@ -1048,7 +853,7 @@ function intercept(side) { // miss reason is drawn on the HUD (TUNE.missHud). const r2 = x => Math.round(x * 100) / 100; -function logSwing(entry) { +export function logSwing(entry) { // exported for beats.js (the watchdog logs into the same ring) entry.at = r2(G.idleT); G.missLog.push(entry); if (G.missLog.length > 50) G.missLog.shift(); diff --git a/index.html b/index.html index 037da56..6536780 100644 --- a/index.html +++ b/index.html @@ -398,6 +398,6 @@ - + diff --git a/main.js b/main.js index 7f0e749..b3215a1 100644 --- a/main.js +++ b/main.js @@ -5,7 +5,8 @@ import { TUNE, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js'; import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js'; -import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight , toggleTestMode } from './gameplay.js'; +import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, toggleTestMode } from './gameplay.js'; +import { startDuel, resolveInterlude, startBossFight, endBossFight } from './beats.js'; import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js'; import { voice, ambient, menuMusic, menuDuck } from './audio.js'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js'; diff --git a/render.js b/render.js index 4ad572a..f725225 100644 --- a/render.js +++ b/render.js @@ -653,7 +653,7 @@ function drawZombie(zb) { // frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body // grew and shrank once per stride. Oliver: "the brute is still pulsating while walking". The rule // already existed for the wound stages; it belongs to every frame, and it is the same rule - // _slice.py's common scaling is built on (see HANDOFF: heights follow the PNG). + // _slice.py's common scaling is built on (see PROJECT.md: heights follow the PNG). if (SPR[name]) h *= SPR[name].height / SPRITE_H; // feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm. const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05) diff --git a/test.js b/test.js index d1e4cf9..315017b 100644 --- a/test.js +++ b/test.js @@ -61,7 +61,8 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const { G, P, score, ball, setupPoint } = await import('./state.js'); -const { update, startDuel, forceZombies, splatPoint, restartRun, resolveInterlude } = await import('./gameplay.js'); +const { update, forceZombies, splatPoint, restartRun } = await import('./gameplay.js'); +const { startDuel, resolveInterlude, startBossFight, endBossFight } = await import('./beats.js'); const { zombies, spawnZombie } = await import('./zombies.js'); const { TUNE } = await import('./config.js'); @@ -155,5 +156,66 @@ test('a pending horde defers while a duel runs, then fires after it', () => { assert.ok(G.horde, 'the set still gets its horde, after the duel'); }); +// ---- regressions mined from the project log (each of these shipped broken once) ---------------- + +test('a pending horde never pours onto a boss stage', () => { + // commit 7803c83: at game 30 (set AND boss chapter) the set's horde latch used to cash into the + // boss fight. The latch must hold while the boss owns the court, and fire after. + startBossFight(3); + assert.ok(G.bossFight, 'setup: boss on court'); + G.pendingHorde = true; G.seen.horde = true; + G.phase = 'over'; G.overT = 0.01; + update(DT); + assert.equal(G.horde, null, 'the latch must hold during the boss fight'); + assert.ok(G.pendingHorde, 'and stay armed'); +}); + +test('the boss win card is EPIC and survives the set handback (the game-30 clobber)', () => { + // endBossFight once ran the parked 'set' handback LAST, so the routine SET card overwrote the + // EPIC card before it drew a frame. The handback runs first now and the boss card wins. + startBossFight(3); + G.afterDuel = 'set'; G.seen.horde = true; + zombies.length = 0; + endBossFight(); + assert.equal(G.bossFight, 0, 'fight over'); + assert.equal(G.card && G.card.title, 'EPIC!', 'the boss card must not be clobbered by the set card'); + assert.ok(G.pendingHorde, 'the set the boss interrupted still owes its horde'); + for (let i = 0; i < 200 && !G.horde; i++) { update(0.1); zombies.length = 0; } + assert.ok(G.horde, 'and the horde arrives after the boss beat, not during it'); +}); + +test('mgT counts down in every phase and releases the serve lock', () => { + // commit e75b388: the burst clock used to tick only during rally, so a beat that moved the phase + // mid-burst stranded mgT above zero forever — and mgT > 0 blocks serving. Permanent lock. + G.mgT = 0.5; G.gunId = 'gun'; + assert.equal(G.phase, 'pretoss', 'setup: not in rally'); + for (let i = 0; i < 60; i++) { update(DT); zombies.length = 0; } + assert.ok(G.mgT <= 0, 'the burst clock must run out outside rally'); + assert.equal(G.phase, 'pretoss', 'and hand back the serve loop'); +}); + +test('a won duel pays out: the tin and the trainer', () => { + // commit c75bd5b ("pay out every won duel") + the heal that is THE reason to want a duel. + G.hp = G.hpMax - 3; + startDuel(); + G.interlude.introT = 0; + const rackBefore = G.rack.length + (G.temp ? 1 : 0); + resolveInterlude('P'); + const rackAfter = G.rack.length + (G.temp ? 1 : 0); + assert.ok(rackAfter > rackBefore, 'winning the duel must hand over a weapon'); + assert.equal(G.hp, G.hpMax - 3 + TUNE.healPerDuel, 'and the trainer heals healPerDuel'); +}); + +test('a lost duel banks their game — and their set — without a horde', () => { + score.games[1] = 5; + startDuel(); + G.interlude.introT = 0; + resolveInterlude('O'); + assert.equal(G.sets[1], 1, 'their 6th game banks their set'); + assert.deepEqual(score.games, [0, 0], 'games reset'); + assert.ok(zombies.filter(z => z.variant !== 'r').length >= 5, 'the dead lose patience: an instant surge'); + assert.ok(!G.horde && !G.pendingHorde, 'their duel set carries no horde'); +}); + console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report diff --git a/zombies.js b/zombies.js index 0151fb4..a626cba 100644 --- a/zombies.js +++ b/zombies.js @@ -6,7 +6,8 @@ import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; import { G, P, ball, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js'; -import { splatPoint, cheer, spentBall, resolveInterlude, endBossFight, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; +import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; +import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one import { tally, multiKill, endRun } from './stats.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} @@ -121,7 +122,7 @@ export function spawnMachine() { // ---- the chapter boss. One every TUNE.bossEvery games, bigger and tougher each time. It advances // slowly and volleys at you, so it threatens BOTH channels: its shots chip your hp, and letting it // reach you is the ordinary instant death. Reuses the whole existing damage model — it is just a -// body with a lot of hp (see the wound() invariant in HANDOFF), so no weapon needs special-casing. +// body with a lot of hp (see the wound() invariant in PROJECT.md), so no weapon needs special-casing. export function spawnChapterBoss(chapter) { const art = bossArt(chapter); // which of the boss designs this chapter is (every tenth = THE CHAMPION) // Every behaviour field on the design is OPTIONAL and falls back to the shared TUNE number, so a @@ -332,7 +333,7 @@ function pickVariant() { // The late roster, spread WIDE on purpose. They were at 3/8/15 and Oliver had met the whole cast // by about game 10 — "let's introduce them more gradually". One new body roughly every seven // games now, and the ORIGINAL four keep their gates exactly (the curve through ~game 12 was - // already tuned; see the calibration rule in HANDOFF). + // already tuned; see the calibration rule in PROJECT.md). if (g >= 5) pool.push('e'); if (g >= 12) pool.push('h'); if (g >= 20) pool.push('k'); @@ -1125,7 +1126,7 @@ export function updateZombies(dt) { ball.vx *= 0.2; ball.vz *= 0.2; ball.lastHit = 'X'; } continue; // soft balls pass through ON PURPOSE, for racketAI to return — that rally IS the - // feedback that it wasn't hard enough, so no clank here; see HANDOFF + // feedback that it wasn't hard enough, so no clank here; see PROJECT.md } if (zb.variant === 's' && ball.vz < 0 && !(zb.shieldT > 0)) { // chair up, ball at its face: BLOCKED zb.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens