Three more chapters, and at game 100 the champion returns everything

The roster ran out at chapter 6 and then repeated itself; game 70 got the
groundskeeper again, bigger. bossArt() now walks nine designs, one per chapter,
and hands out THE CHAMPION on every tenth — the floor(ch/10) term takes his
chapters back out of the count so chapter 11 opens the second lap on the
groundskeeper rather than skipping him.

  70 THE STRINGER — leather apron, bandoliers of gut string, racket frames on
     his back. He takes your lane and puts two dead-straight ones on it.
  80 THE TOURNAMENT DIRECTOR — blazer, lanyard, radio. `adds` is new: he calls
     stewards onto the court while you fight him, which is the one thing a boss
     fight could not do before, since the stage is cleared for it.
  90 THE STRAWBERRY SELLER — tray of cream, apron soaked red, ladle. A tank
     that sprays four at once and then comes for you.
 100 THE CHAMPION — torn whites, a laurel of strapping tape, the trophy still
     in one fist. `rally`: anything under splatter quality comes straight back
     at your head, so he is the one body you have to out-HIT rather than
     out-shoot. `phases` lays three rows over his own as his health falls — he
     holds the baseline and rallies, then runs the line drilling volleys, then
     drops to a charge — and each turn is an event: cry, shake, the stand up.

Thirty-six frames through assets/_regen.py, the mascot's three-batch recipe
(2 stride, 3 wounds, 4 throws). The trap that cost the re-rolls: a throw batch
references its own wound frames, so a wounds batch that fails leaves the throw
sheet drawn against the SEED — two of the four came back half groundskeeper.
Wounds first, verify, then throws.

And the physio, who Oliver reported "does basically nothing but move towards
the net": he kept 4.2 of every 6.3 m he charged, so the fight was always over
before he arrived. `withdraw` is his own number now (0.2) and he charges half
again as fast — measured, he is on top of you in 11 s instead of never — and he
drills three balls a cycle on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-09 16:08:57 +02:00
1 parent f4c6f408e7
commit 04ee0174ce
42 files changed
+353 -72

No files matched your search

+57 -11
View File
@@ -3,7 +3,7 @@
// your baseline — one crossing it is a point for THEM.
// (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.)
import { TUNE, UI, COURT, CONTACT, BOSS_ART, 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 { 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';
@@ -124,7 +124,7 @@ export function spawnMachine() {
// 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.
export function spawnChapterBoss(chapter) {
const art = BOSS_ART[(chapter - 1) % BOSS_ART.length]; // which of the boss designs this chapter is
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
// design row that carries nothing but art behaves exactly the way a boss did before there were any.
const hp = Math.round((TUNE.bossHp + (chapter - 1) * TUNE.bossHpPerChapter) * (art.hpMul ?? 1));
@@ -152,22 +152,38 @@ export function spawnChapterBoss(chapter) {
// motion is "close on the player's x" arrives, stops, and is static for the rest of the fight, which
// is the exact complaint this work exists to fix. Every style below therefore moves under its OWN
// power and only BIASES toward the player. Measured: each of the six covers 5-14 m a minute.
// WHICH PHASE a boss is in, or -1 for a design that has none. Health drives it, so it is derived
// rather than stored and cannot desync from the hp it is meant to describe.
const bossPhase = zb => {
const n = zb.art?.phases?.length;
return n ? Math.min(n - 1, Math.floor((1 - zb.hp / zb.hpMax) * n)) : -1;
};
// The design a boss is FIGHTING as, this instant: its own row, with the current phase's fields laid
// over it. Everything that reads a boss's behaviour goes through here, so a phase stays a data row
// and never becomes a second code path. (There is one boss on court; the object per call is free.)
export const bossPlan = zb => {
const A = zb.art || {}, i = bossPhase(zb);
return i < 0 ? A : { ...A, ...A.phases[i] };
};
function bossAI(zb, dt) {
if (zb.windT > 0 || zb.salvo > 0) return; // planted: throwing is not a moving action
const x0 = zb.x, z0 = zb.z, style = zb.art?.move || 'advance';
const strafe = TUNE.bossStrafe * (zb.art?.paceMul ?? 1);
const x0 = zb.x, z0 = zb.z, A = bossPlan(zb), style = A.move || 'advance';
const strafe = TUNE.bossStrafe * (A.paceMul ?? 1);
if (style === 'charge') {
zb.moveT -= dt;
const v = TUNE.bossChargeV * (A.speedMul ?? 1);
if (zb.charging) {
const dx = P.x - zb.x;
zb.x += Math.sign(dx) * Math.min(Math.abs(dx), TUNE.bossStrafe * dt);
zb.z = Math.min(P.z, zb.z + TUNE.bossChargeV * dt); // AT you and NO FURTHER: unclamped it ran
zb.z = Math.min(P.z, zb.z + v * dt); // AT you and NO FURTHER: unclamped it ran
if (zb.moveT <= 0) { zb.charging = false; zb.moveT = TUNE.bossChargeRest; } // off the back of the court
} else {
// It withdraws LESS than it charged, so the fight has a visible clock: it is a couple of metres
// nearer after every rush and reaches you in about half a minute. A withdrawal that undid the
// whole charge made "the body is the weapon" untrue — it never once got near you.
zb.z = Math.max(TUNE.bossZMin, zb.z - TUNE.bossChargeV * 0.45 * dt);
// It withdraws LESS than it charged, so the fight has a visible clock: it is nearer after every
// rush and arrives while you are still fighting it. A withdrawal that undid the whole charge
// made "the body is the weapon" untrue — it never once got near you, which is exactly what the
// physio's old 0.45 still did over a whole fight (`withdraw` is per design now).
zb.z = Math.max(TUNE.bossZMin, zb.z - v * (A.withdraw ?? 0.45) * dt);
if (zb.moveT <= 0) { zb.charging = true; zb.moveT = TUNE.bossChargeT; }
}
} else if (style === 'shadow') {
@@ -214,6 +230,17 @@ function bossAI(zb, dt) {
zb.dist = (zb.dist || 0) + Math.hypot(zb.x - x0, zb.z - z0);
}
// HE CALLS SECURITY. A design with `adds` puts bodies on the court while you fight it — the one
// thing a boss fight could not do before, since startBossFight clears the stage and normal spawning
// is off for the duration. `cap` counts the boss itself, so the court cannot silt up while a slow
// player works on a big health bar.
function bossAdds(zb, dt) {
const A = bossPlan(zb);
if (!A.adds || (zb.addT = (zb.addT ?? A.adds.every) - dt) > 0) return;
zb.addT = A.adds.every;
if (zombies.length < (A.adds.cap ?? 5)) spawnZombie((Math.random() * 2 - 1) * 5, -1.5 - Math.random(), A.adds.variant);
}
// ---- the player's second health channel. Only these two functions touch it.
export function hurtPlayer(n) {
// G.hurtT is the grace window: a volley must not delete you at once. G.dodgeI is the SIDESTEP —
@@ -515,9 +542,18 @@ function ignite(x, z, r) {
// needs no new code: every weapon is already proportional to it.
function wound(zb, amount, fx = true) {
zb.chip = (zb.chip || 0) + amount;
const ph = bossPhase(zb);
let took = 0;
while (zb.chip >= 1 && zb.hp > 0) { zb.chip -= 1; zb.hp--; took++; }
if (took && fx) { splat(0.5, bodySize(zb)); woundBurst(zb); }
// A PHASED BOSS TURNING IS AN EVENT, not a silent change of numbers. Same body, new fight: the
// crowd comes up, the court shakes, and he says which set this is. Without the beat the champion's
// second gear would arrive as "the boss suddenly got faster" and read as the game glitching.
if (zb.hp > 0 && bossPhase(zb) > ph) {
const cry = bossPlan(zb).cry;
if (cry) G.flash = { text: cry, t: 1.6 };
G.shake = { t: 0.4, mag: 6 }; cheer(1.4, 0.7); moan(0.7);
}
return zb.hp <= 0;
}
@@ -922,11 +958,12 @@ export function updateZombies(dt) {
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
if (zb.hitT > 0) zb.hitT -= dt; // boss contact cooldown (see TUNE.bossHitCool)
if (zb.retT > 0) zb.retT -= dt; // the champion's return stroke, held long enough to read
// ---- the two RANGED attackers. Both wind up visibly before firing (TUNE.machWind / drawZombie's
// charge glow) because a shot you cannot see coming is not a fairness contract, it is a gotcha.
if (!held && !G.interlude && (zb.variant === 'm' || zb.variant === 'B') && G.mode === 'splatter'
&& zb.rollTo === undefined) { // a cart on the roll cannot aim — that is the window to close on it
const boss = zb.variant === 'B', A = zb.art;
const boss = zb.variant === 'B', A = boss ? bossPlan(zb) : zb.art;
if (zb.salvo > 0) { // mid-volley: the rest of the balls are still coming
zb.salvoT -= dt;
// A gap of 0 means the WHOLE volley leaves in this one frame — the mascot's wall of five,
@@ -1004,7 +1041,7 @@ export function updateZombies(dt) {
if (zb.hp < zb.hpMax * 0.5 && Math.random() < dt * 6) smokePuff(zb.x, 1.1, zb.z, 0.25); // wrecked: it smokes where it stands
}
else if (held || zb.variant === 'r') { /* nothing moves on a changeover; a duelling opponent owns his x */ }
else if (zb.variant === 'B') { if (G.mode === 'splatter' && !G.interlude) bossAI(zb, dt); }
else if (zb.variant === 'B') { if (G.mode === 'splatter' && !G.interlude) { bossAI(zb, dt); bossAdds(zb, dt); } }
else if (G.mode === 'splatter' && zb.z > COURT.netZ) { // on YOUR half they close in — each on its own lane, not a column
const tx = P.x + zb.homeOff;
zb.x += Math.sign(tx - zb.x) * Math.min(Math.abs(tx - zb.x), zb.v * 0.7 * dt)
@@ -1051,6 +1088,15 @@ export function updateZombies(dt) {
ball.vx *= 0.3; ball.vz *= 0.3; ball.lastHit = 'X'; // spent on the hit
if (dead) { splatter(i); continue; } // killing blow: full gore + point
}
// ...AND THE CHAMPION RETURNS THE REST. Every other body in the game can be worn down with
// soft balls; the final boss sends anything under splatter quality straight back at your
// head, which is what makes him the one you have to out-HIT. The ball is spent on his
// racket and comes back as an incoming shot — parry it, swish it away, or step aside.
else if (bossPlan(zb).rally) {
fireAtPlayer(zb.x, zb.z, (Math.random() * 2 - 1) * 0.4, bossPlan(zb).shot ?? TUNE.bossShotSpeed);
zb.retT = 0.3; // the throw frame, so the return is drawn
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
}