One file owns the court: beats.js, PROJECT.md (né HANDOFF), and tests gate the build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-11 13:23:27 +02:00
1 parent 05930f0877
commit c122745390
11 files changed
+362 -231

No files matched your search

+12
View File
@@ -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.
+9
View File
@@ -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 FROM python:3.12-alpine
# Was nginx:alpine. The leaderboard needs one process that can WRITE, and nginx is not one. # 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, # 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. # 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. # ponytail: switch to hashed filenames if traffic ever matters.
WORKDIR /app WORKDIR /app
COPY --from=test /tests-passed /tmp/tests-passed
COPY index.html manifest.webmanifest *.js server.py /app/ COPY index.html manifest.webmanifest *.js server.py /app/
COPY assets /app/assets COPY assets /app/assets
# THE SCORES LIVE HERE AND THIS MUST BE A PERSISTENT VOLUME. Without one the json sits in the # THE SCORES LIVE HERE AND THIS MUST BE A PERSISTENT VOLUME. Without one the json sits in the
+43 -19
View File
@@ -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 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 long history, `~/.claude/projects/.../memory/splatter-tennis-history.md` is auto-loaded; for the
detail of any individual change, read the git log. 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 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 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 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) ## Module map (all in repo root)
`config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object + `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()` + 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 + transition + `splatPoint` + ball-boy routines · `beats.js` **the beat machinery: every mode
`updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing · `input.js` keys/touch/flick · transition (duel/horde/boss/changeover/`proceedAfterClear`) — ONE MODE OWNS THE COURT, and each
`audio.js` sfx + recorded umpire (`assets/audio/*`, macOS `say -v Daniel`) · `panel.js` the T panel · `start*` enforces that itself; a new mode starts there and refuses there** · `zombies.js`
`main.js` boot, loop, scoreboard test-mode taps, `window.T` test surface (the umpire-tap cheat is zombie/gore/particle state + `updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing ·
removed — the testteam found it; `T.forceZombies()` is the console replacement). `input.js` keys/touch/flick · `audio.js` sfx + recorded umpire (`assets/audio/*`, macOS
THREE runtime-only import cycles (documented in headers): input↔gameplay, zombies↔gameplay, `say -v Daniel`) · `panel.js` the T panel · `main.js` boot, loop, scoreboard test-mode taps,
and state↔zombies (`sendBoy` reads `gore` to decide whether a mopping round is worth walking out for). `window.T` test surface (the umpire-tap cheat is removed — the testteam found it;
All three are safe for the same reason: the imported binding is only ever touched inside a function at `T.forceZombies()` is the console replacement) · `test.js` the headless regression suite
runtime, never during module evaluation. (`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 ## How the game flows
Tennis → win a game → **transition** (`G.mode='transition'`, timeline on `G.transT`) → **splatter**. 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 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 `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. 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 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 — 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 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. Widest string checked fits 270px portrait with ~10px spare.
- **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`. - **Weapons are only ever WON.** `deliverTin()` has exactly one caller: `resolveInterlude('P')`.
The schedule (`TUNE.duelFirst`/`duelEvery`, `G.duelDue`) calls the **duel**, fired from 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. 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 — - **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`, 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 `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. 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) ## 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 Oliver hit the horde freeze a THIRD time, earlier in a run: **a sudden-death duel started inside a
+217
View File
@@ -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 };
}
+2 -2
View File
@@ -46,7 +46,7 @@ export const TUNE = {
// ---- THE DIFFICULTY DIRECTOR. Every dial below used to bottom out by about game 12, which is why // ---- 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 // 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 // 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 // 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. // 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 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, // 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. // 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 // ---- 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. // 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 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 hurtGrace: 1.1, // s of invulnerability after a hit — a burst must not delete you in one volley
+7 -202
View File
@@ -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 { 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 { 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 { 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 ---- // ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. // 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 — // (proceedAfterClear and every start*/end* mode transition live in beats.js — one file owns who
// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde. // has the court. splatPoint above only ARMS the schedule latches; beats.js decides when they fire.)
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 courtside rack. RACK_MAX + 1 slots, and only slot 0 is fixed: it is BALL (infinite), or // ---- 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. // 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 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 } // 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. // (startDuel, startBossFight/endBossFight, the deadlock watchdog and resolveInterlude live in
// Tennis rules apply for exactly one point — the absurdity peak: the umpire never stopped watching. // beats.js — see its header for the one-owner rule.)
// 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();
}
}
// retire the splatter ball; a running changeover's long beat must not be clobbered short // retire the splatter ball; a running changeover's long beat must not be clobbered short
export function spentBall() { 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 xVisible = () => Math.min(P_BOUNDS.x, (W / 2 - 14) / (TUNE.camF / (TUNE.camZ - P.z)));
const xLimit = () => Math.max(xVisible(), COURT.W / 2); const xLimit = () => Math.max(xVisible(), COURT.W / 2);
// the swarm keeps coming: no serving, the racket auto-fires, the thumb drag steers you // (startHorde lives in beats.js)
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 };
}
// 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 // 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 // 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 // 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). // miss reason is drawn on the HUD (TUNE.missHud).
const r2 = x => Math.round(x * 100) / 100; 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); entry.at = r2(G.idleT);
G.missLog.push(entry); G.missLog.push(entry);
if (G.missLog.length > 50) G.missLog.shift(); if (G.missLog.length > 50) G.missLog.shift();
+1 -1
View File
@@ -398,6 +398,6 @@
</div> </div>
</div> </div>
</div> </div>
<script type="module" src="main.js?v=147"></script> <script type="module" src="main.js?v=148"></script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -5,7 +5,8 @@
import { TUNE, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js'; import { TUNE, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.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 { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.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'; import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
+1 -1
View File
@@ -653,7 +653,7 @@ function drawZombie(zb) {
// frame sliced 13 px shorter than its neighbours was being stretched back to full size — the body // 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 // 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 // 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; if (SPR[name]) h *= SPR[name].height / SPRITE_H;
// feeding: a fast bob over the body instead of a stride. Same sprites, different rhythm. // 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) const bob = zb.feeding ? Math.abs(Math.sin(zb.walkT * 11)) * Math.max(1, p0(zb) * 0.05)
+63 -1
View File
@@ -61,7 +61,8 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ---------------------------------------------- // ---- game modules (real code) ----------------------------------------------
const { G, P, score, ball, setupPoint } = await import('./state.js'); 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 { zombies, spawnZombie } = await import('./zombies.js');
const { TUNE } = await import('./config.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'); 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`); console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
+5 -4
View File
@@ -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 { 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 { 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 { 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'; import { tally, multiKill, endRun } from './stats.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} 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 // ---- 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 // 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 // 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) { export function spawnChapterBoss(chapter) {
const art = bossArt(chapter); // which of the boss designs this chapter is (every tenth = THE CHAMPION) 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 // 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 // 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 // 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 // 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 >= 5) pool.push('e');
if (g >= 12) pool.push('h'); if (g >= 12) pool.push('h');
if (g >= 20) pool.push('k'); 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'; 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 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 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 zb.shieldT = TUNE.shieldDown; // ...but the block staggers it — the chair drops, kill window opens