Add chapter bosses and a second damage channel; demote the racket zombie

Steps 4 and 5 of the late-game depth plan.

The ranged channel. A rogue ball machine gets wheeled onto the far court every
four games: it never walks, never crosses your baseline and never scores, so
it is a standing threat you choose when to deal with — the target-priority
decision the game was missing. Its shot is a real arc solved like a serve, not
a flat bolt, so it reads coming in and the swing window has something to meet.
Three outcomes: a swing inside the volley window returns it as your own ball
flying up-court (an extras clone, so "a parry is a weapon" cost nothing to
build), the panic swish bats it away, and letting it reach you costs blood.
The swish path is load-bearing, not politeness: in pretoss the player is
rooted and cannot swing at all.

Contact still kills outright — that stays a separate channel, and killPlayer()
is now the single death path for both causes so the run summary and the best-run
write cannot drift. Health is drawn on the man: procedural spatter at fixed
anchors, a live trickle, and a red wash from the screen edges. No bar. Healing
is winning a duel, which finally gives the duel a reason to be wanted.

Chapter bosses every ten games, with their own sheet — a colossal zombie
groundskeeper hauling a ball hopper, coming apart through arm-off, ribs-out and
headless-still-throwing. Chapter 1 is 10 hp and 2.7 m, chapter 4 is 34 hp and
3.67 m. It outranks the set and duel beats at a clear, because a boss walking
into a standing wave is unreadable. And it fixes the economy: a chapter-4 boss
costs 12 bombs or 100 rounds or 34 side balls against a per-type cap of 9, so
no single weapon can take one and a banked mixed arsenal is mandatory.

The racket zombie is demoted to an occasional opponent, as asked — appearing
every won game is exactly why he read as the boss.

Five silent bugs found on the way, all in HANDOFF: a blast deleted a 34 hp boss
outright (isBoss() exists to stop that class recurring), the machine's hp did
nothing because it fell through to the one-shot fallback, the boss both scored
and fired its card, the machine gated the hand-clear, and a boss died with a
shambler-sized gore burst.

Tennis untouched: 30 tapped points still 50 hits / 0 whiffs. 45-game soak clean
with all four chapters firing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-07-30 12:55:35 +02:00
1 parent ffac9b5512
commit 4a066e5cc6
14 files changed
+492 -131

No files matched your search

+75 -7
View File
@@ -5,7 +5,7 @@ import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } fr
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck } from './audio.js';
import { axisX, axisZ, takeFlickAim, touchPX } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateZombies, spawnZombie, spawnRacketZombie, zombies, gore, explode, meleeSwipe, extras, parts, pellets, playerGore } from './zombies.js';
import { updateZombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore } from './zombies.js';
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
@@ -284,6 +284,11 @@ function pointTo(side, reason) {
// quiet (during a horde) rate-limits the umpire so he doesn't call every rapid-fire point.
// The scoreboard IS the progression: games drive spawn pressure, 6 games = a set = a horde,
// odd games end in a changeover breather.
// Which bodies gate the hand-clear after a won game. The racket zombie keeps its residence, and the
// ball machine is furniture — a standing threat you deal with WHEN YOU CHOOSE. Making either block
// progression would turn "decide what to prioritise" into "clear the chore list".
const blocksClear = z => z.variant !== 'r' && z.variant !== 'm';
export function splatPoint(side, quiet = false) {
if (side === 'P' && ++G.killCount < TUNE.killsPerPoint) return; // K kills bank one point; the rest just splatter
if (side === 'P') G.killCount = 0;
@@ -295,6 +300,13 @@ export function splatPoint(side, quiet = false) {
if (G.runGames === TUNE.duelFirst ||
(G.runGames > TUNE.duelFirst && (G.runGames - TUNE.duelFirst) % TUNE.duelEvery === 0))
G.duelDue = true;
// ...and every bossEvery games the schedule calls a CHAPTER BOSS. Like the duel it fires from
// proceedAfterClear(), the one beat where the court is guaranteed empty, and it outranks
// everything else there — a boss must not walk on into a standing wave.
if (G.runGames % TUNE.bossEvery === 0)
G.bossDue = Math.max(G.bossDue, Math.floor(G.runGames / TUNE.bossEvery)); // never backwards; a
// long stretch with no clear skips a chapter rather than queueing one, which is intended
voice(side === 'P' ? 'game_set_massacre' : 'game_opponent', 0.5); // game-win lines always speak
if (side === 'P') cheer(2.2, 0.5); else aww(0.85); // your game = applause, theirs = a groan
const i = side === 'P' ? 0 : 1;
@@ -302,12 +314,20 @@ export function splatPoint(side, quiet = false) {
else if ((score.games[0] + score.games[1]) % 2 === 1) G.afterClear = 'changeover'; // real rule: after odd games
else G.afterClear = 'game'; // even game: a beat, then the next wave
G.clearWinner = side;
if (side === 'P' && !zombies.some(z => z.variant === 'r'))
spawnRacketZombie(); // every game you win, an opponent takes the far court (rally it, or splat it)
// The racket zombie is DEMOTED: he used to appear on every single game you won, which is exactly
// why he read as "the boss" and why a real boss would not have stood out next to him. He is an
// occasional opponent now — every racketEvery games — and the chapter boss is the wall.
if (side === 'P' && G.runGames % TUNE.racketEvery === 0 && !zombies.some(z => z.variant === 'r')
&& !G.bossFight)
spawnRacketZombie();
// the rogue ball machine gets wheeled out on the same kind of schedule, capped on court
if (side === 'P' && G.runGames >= TUNE.machEvery && G.runGames % TUNE.machEvery === 0
&& zombies.filter(z => z.variant === 'm').length < TUNE.machMax && !G.bossFight)
spawnMachine();
// The wave does NOT auto-collapse anymore: the survivors stay and must be cleared BY HAND before
// play advances — only no NEW shamblers spawn. When the last one is down, proceedAfterClear()
// fires (see update()). If the winning shot already emptied the court, proceed at once.
if (zombies.some(z => z.variant !== 'r')) {
if (zombies.some(blocksClear)) {
G.clearing = true; ball.active = false;
G.flash = { text: 'CLEAR THE COURT', t: 1.8 };
G.phase = 'over'; G.overT = TUNE.gameBeatT; // a short beat to register the game, then the serve loop resumes for the mop-up
@@ -326,7 +346,10 @@ export function proceedAfterClear() {
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 set/horde beat outranks it; the duel just waits for the next clear.
// 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)
@@ -408,6 +431,45 @@ export function startDuel() {
} 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; G.clearing = false;
ball.active = false; setupPoint();
spawnChapterBoss(chapter);
zombieDuck(true); // the music drops out for it
voice('quiet_please', 0, 0.8); voice('sudden_death', 1.6, 0.85);
G.card = { title: 'CHAPTER ' + chapter, sub1: 'SOMETHING BIG IS COMING', sub2: 'IT THROWS. RETURN IT.',
color: UI.bad, t: TUNE.setCardT };
G.flash = null;
if (!G.seen.boss) { // the rules change: a wall, and a thing that shoots back
G.seen.boss = true;
showModal({ title: 'IT BRINGS A MACHINE', color: UI.bad,
body: ['IT THROWS BALLS AT YOU', 'SWING TO RETURN THEM — OR SWISH TO BAT THEM AWAY',
'A HIT COSTS BLOOD. WIN A DUEL TO HEAL.'] });
}
}
// 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);
G.card = { title: 'CHAPTER ' + c + ' DOWN', sub1: 'THE STANDS ARE ON THEIR FEET',
sub2: 'GAME ' + (G.runGames + 1) + ' NEXT', color: UI.pick, t: TUNE.setCardT };
ball.active = false; G.phase = 'over'; G.overT = TUNE.setCardT;
// hand back the game beat the boss interrupted — same contract resolveInterlude uses
if (G.afterDuel) { G.afterClear = G.afterDuel; G.afterDuel = null; proceedAfterClear(); }
}
export function resolveInterlude(side) {
if (!G.interlude) return;
G.interlude = false;
@@ -415,6 +477,12 @@ export function resolveInterlude(side) {
ball.active = false; G.phase = 'over'; G.overT = 2.2;
if (side === 'P') {
cheer(3, 0.7);
// winning the duel patches you up. 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.
if (G.hp < G.hpMax) {
G.hp = Math.min(G.hpMax, G.hp + TUNE.healPerDuel);
G.flash = { text: 'PATCHED UP', t: 1.4 };
}
deliverTin(); // win the point, win the next tin — on the spot
} else {
voice('game_opponent', 0.4); aww(0.85); // lost the sudden-death duel
@@ -904,14 +972,14 @@ export function update(dt) {
// --- zombies + transition timeline
if (G.mode !== 'tennis') updateZombies(dt);
if (G.meleeT > 0) G.meleeT -= dt;
if (G.clearing && !zombies.some(z => z.variant !== 'r')) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.clearing && !zombies.some(blocksClear)) proceedAfterClear(); // the won-game wave is mopped up — advance
if (G.mode === 'transition') transition(dt);
// --- interlude trigger: the court is clear, an opponent holds the far court, you're about
// to serve — the umpire recognizes a legal match and restores order. One point. Sudden death.
// The schedule is a FLOOR, not a ceiling: this lucky-clear trigger stays, and a bonus duel is
// pure upside for weapon supply.
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0)
if (G.mode === 'splatter' && !G.interlude && !G.horde && !G.changeover && !(G.mgT > 0) && !G.bossFight
&& G.phase === 'pretoss' && zombies.length === 1 && zombies[0].variant === 'r' && !zombies[0].dueled)
startDuel();
if (G.interlude && G.interlude.introT > 0) G.interlude.introT -= dt;