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:
1 parent
ffac9b5512
commit
4a066e5cc6
14 files changed
+492
-131
No files matched your search
+75
-7
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user