Make the bosses move, and put six of them and three new bodies on court

A chapter boss had no AI at all: it matched no branch in the variant chain and
fell through to the crowd's aimless wobble, so "the boss AI owns its x" was a
comment describing code that did not exist. Movement is now data on the BOSS_ART
row -- pace, shadow, surge, charge, bounce -- and bossAI is the one place it
happens. Every behaviour field is optional, so a row carrying only art behaves
exactly as a boss did before.

Three earlier drafts of those styles failed the same way: written as "walk toward
the player's x", which in splatter means arrive in four seconds and stand still,
because the player is rooted in pretoss for nearly all of a boss fight. Every
style now moves under its own power and only biases toward him. Measured over a
simulated minute each: 50-136 m travelled, none static.

Three new designs -- THE HEAD COACH (a 5-shot drill 0.14s apart), THE PHYSIO
(charges, and withdraws less than it charges, so it reaches you at 38s and that
is the fight's clock), THE SPONSOR MASCOT (gap 0, so the whole wall of five
leaves in ONE frame and is dodged rather than parried). Harder to kill: 16 -> 20
hp, 9 -> 10 per chapter, plus per-design multipliers.

Three new bodies: the ball boy (3 hp, 2 skins, from game 3), the bouncer (6/3,
game 8) and the roller man (9/4, game 15), each with a 4-frame walk and a drawn
two-frame lurch for every wound stage.

Three wound rules became one. The brute had a hardcoded stage array, everything
else a single skin behind 'abd'.includes(...), and the boss a fraction of hpMax.
The boss's rule reproduces the other two exactly -- verified: the brute still
draws z_gw/gw2/gw3/gw4 at hp 4/3/2/1 -- and keeps working when hp is not
one-per-skin, which all three new bodies need. So a multi-hit body is now a row
in config.ZWOUND and no code. bleed() reads the same call, which also gave the
steward the trickle his wound skin had always implied.

Fixes a real crash on the way past: killPlayer empties shots[] while the shots
loop is mid-iteration, so every remaining index read undefined and the frame
threw. The mascot's five-at-once volley makes it easy to hit; the shipped 3-shot
volley could always have hit it.

_regen.py gains two style blocks and a check tier. One constant used to say
"keep every colour exactly as the reference" -- right for a remaster, and it
fights every word of the subject otherwise. newart is for a new character, state
for the same character wounded or mid-throw; using newart for the wound sheets
licensed a repaint and got one, five came back with living tan skin. And the
detail gate ratchets against the file it replaces, so check: any exists for
re-rolling already-fine art to fix something the gate cannot see, such as colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-03 11:45:35 +02:00
1 parent 0774a2a31d
commit 4cda6bde76
64 files changed
+694 -78

No files matched your search

+43 -30
View File
@@ -1,7 +1,7 @@
// Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state).
import { cv, ctx } from './dom.js';
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, BOUNCE_IDLE, SPRITE_H, zombieH } from './config.js';
import { TUNE, UI, EATEN_GRAB, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH } from './config.js';
import { P, O, ball, score, crowd, G, court } from './state.js';
import { predictLanding, previewServe } from './gameplay.js';
import { zombies, gore, piles, parts, pellets, extras, shots } from './zombies.js';
@@ -181,7 +181,7 @@ function drawRunOff() {
// ---------------------------------------------------------------- sprites ----
// assets/<name>.png; game falls back to code-drawn figures until sprites load.
const SPRV = 79; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPRV = 80; // asset cache-buster — bump with main.js?v=N (see the img.src note below)
const SPR = {};
for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','p_run1','p_run2','p_run3','p_run4','p_swing1','p_swing2','p_die1','p_die2','p_die3','p_die4','p_die5',
'p_pull', // the serve wind-up: the racket dropping behind him while the pull is drawn
@@ -196,8 +196,17 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
'z_boss1','z_boss2','z_bossw1','z_bossw2','z_bossw3', // the CHAPTER BOSS: 2-frame stride + three wound stages (arm off -> ribs out -> headless)
'z_bosst0','z_bosst1','z_bosst2','z_bosst3', // ...and each of those four states mid-THROW (windT)
'z_mach1','z_mach2','z_machw1','z_machw2', // the ball machine: idle, firing, dented, split open
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 5, ...)
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 6, ...)
'z_judge1','z_judge2','z_judgew','z_judget','z_judgewt', // boss design 2 (chapter 2, 8, ...)
'z_chair1','z_chair2','z_chairw','z_chairt','z_chairwt', // boss design 3 (chapter 3, 9, ...)
// designs 4-6, each with the full set the groundskeeper has: 2 stride, 3 wounds, 4 throws
'z_coach1','z_coach2','z_coachw1','z_coachw2','z_coachw3','z_coacht0','z_coacht1','z_coacht2','z_coacht3',
'z_physio1','z_physio2','z_physiow1','z_physiow2','z_physiow3','z_physiot0','z_physiot1','z_physiot2','z_physiot3',
'z_mascot1','z_mascot2','z_mascotw1','z_mascotw2','z_mascotw3','z_mascott0','z_mascott1','z_mascott2','z_mascott3',
// the late roster: ball boy (3 hp, 2 skins), bouncer (6 hp, 3), roller man (9 hp, 4).
// Every skin has its …b stride twin, so a wounded body keeps walking (see ZWOUND).
'z_e1','z_e2','z_e3','z_e4','z_ew','z_ewb','z_ew2','z_ew2b',
'z_h1','z_h2','z_h3','z_h4','z_hw','z_hwb','z_hw2','z_hw2b','z_hw3','z_hw3b',
'z_k1','z_k2','z_k3','z_k4','z_kw','z_kwb','z_kw2','z_kw2b','z_kw3','z_kw3b','z_kw4','z_kw4b',
'z_a1','z_a2','z_a3','z_a4','z_b1','z_b2','z_b3','z_b4','z_c1','z_c2',
'z_d1','z_d2','z_d3','z_d4', // the steward: bigger body, hi-vis vest, 4-frame walk
'z_awb','z_bwb','z_cw1b','z_cw2b','z_dw','z_dwb', // ...and every wound skin's second frame: wounded bodies walk too
@@ -307,7 +316,10 @@ function drawBoy(b) {
// for 'z_g-1' — a sprite that does not exist, and drawStatic silently draws nothing. That is the
// blinking Oliver saw on the way in, on exactly the bodies that step off distance: brutes and bosses.
const stepFrame = (v, n) => ((Math.floor(v) % n) + n) % n; // 0..n-1, whatever the sign of v
const ZFRAMES = { a: 4, b: 4, f: 4, d: 4, g: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
const ZFRAMES = { a: 4, b: 4, e: 4, f: 4, d: 4, g: 4, h: 4, k: 4, s: 4, c: 4 }; // 4-frame walk cycles where sheets exist; the rest stay 2
// Bodies whose stride steps off GROUND COVERED instead of the clock. They are the slow heavy ones,
// and a time-driven cycle on them is pure foot-slide (the defect the brute's rule was written for).
const ZSTRIDE = { g: TUNE.bruteStride, k: TUNE.rollerStride };
// The rogue ball machine, on its own generated sheet: a dented hopper cart on wheels with a barrel
// aimed down-court, and a second frame with the muzzle flashing. Oliver called the first, procedural
// version "naively painted" — it was four rects, which is fine for fire but not for furniture the
@@ -347,9 +359,9 @@ function drawZombie(zb) {
// the brute's — standing still is standing still — and his swing keeps its own frame.
let frame = rk && zb.pose === 'swing' ? 2
: rk ? stepFrame((zb.dist || 0) / TUNE.racketStride, N) + 1
: zb.variant === 'g' ? stepFrame(zb.z / (TUNE.bruteStride * 2 / N), N) + 1 // heaviest body: step off DISTANCE
// WALKED, like its wound lurch below,
// or the feet slide (same cycle length)
: ZSTRIDE[v] ? stepFrame(zb.z / (ZSTRIDE[v] * 2 / N), N) + 1 // heaviest bodies: step off DISTANCE
// WALKED, like their wound lurch below,
// or the feet slide (same cycle length)
: Math.floor(zb.walkT * (zb.variant === 'f' ? 6 : 2.6) * N / 2) % N + 1;
const chairDown = zb.variant === 's' && zb.shieldT > 0; // chair dropped — hit it NOW. Its own sprite
// now (z_sdown); it used to be walk frame 3,
@@ -362,25 +374,23 @@ function drawZombie(zb) {
// shambling). The blood is NOT painted in — open wounds gush live particles (see bleed() in
// zombies.js). Fall back to the healthy sprite until the wounded PNG has loaded.
let name = chairDown && SPR.z_sdown ? 'z_sdown' : 'z_' + v + frame;
if (zb.variant === 'g' && zb.hp < TUNE.bruteHp) { // one arm → both → head gone → ribs out
const stage = ['z_gw4', 'z_gw3', 'z_gw2', 'z_gw'][Math.max(0, Math.min(3, zb.hp - 1))];
// ONE lookup for every multi-hit body that reads damage against absolute hp. config.ZWOUND is
// worst-first and indexed by REMAINING hp − 1, which is the brute's old hardcoded stage array
// generalised; a single-skin body clamps to index 0 and falls out of the same line. This replaced
// two branches that did the same job against different constants, which is why the ball boy, the
// bouncer and the roller man each cost a table row here and no code at all.
const stages = ZWOUND[zb.variant], sIdx = stages ? woundStage(zb, stages.length) : 0;
if (sIdx) {
const stage = stages[sIdx - 1];
if (SPR[stage]) {
// Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). Stepped by
// DISTANCE WALKED, not time — the brute is the slowest thing on court, so a time-driven cycle
// was pure foot-slide. This replaced a procedural squash-and-sway that read as exactly what
// it was: fake. The comedy is in the gait, and the gait has to be drawn.
const b = stage + 'b';
name = SPR[b] && stepFrame(zb.z / TUNE.bruteStride, 2) ? b : stage;
} else if (SPR.z_gw) name = 'z_gw'; // a missing asset degrades to the last skin that loaded
}
else if ('abd'.includes(zb.variant) && zb.hp < (zb.hpMax || 0) && SPR['z_' + zb.variant + 'w']) {
// A wounded body used to freeze on ONE frame and slide across the court — the wound skins were
// single stills. Every skin has its …b twin now (the stride swapped), stepped off the same clock
// as the healthy walk, so losing an arm changes how he walks and not whether he walks.
const w = 'z_' + zb.variant + 'w';
// Same CADENCE as the healthy walk (which runs four frames at 5.2/s), or a wounded body reads
// as frozen next to the crowd it is walking in — it was stepping at half the rate on two frames.
name = SPR[w + 'b'] && stepFrame(zb.walkT * 5.2, 2) ? w + 'b' : w; // gashed shambler, still lurching
// Every wound stage has its OWN two-frame lurch (…w / …wb = the stride swapped). The heavy
// bodies step it by DISTANCE WALKED (a time-driven cycle on them was pure foot-slide, and
// replaced a procedural squash-and-sway that read as exactly what it was: fake); everything
// else steps at the SAME cadence as its healthy walk, or a wounded body reads as frozen next
// to the crowd it is walking in. The comedy is in the gait, and the gait has to be drawn.
const b = stage + 'b', phase = ZSTRIDE[v] ? zb.z / ZSTRIDE[v] : zb.walkT * 5.2;
name = SPR[b] && stepFrame(phase, 2) ? b : stage;
} else if (SPR[stages[0]]) name = stages[0]; // a missing asset degrades to the lightest skin that loaded
}
// The boss's damage is read as a FRACTION of full health — his hp scales with the run
// (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run
@@ -404,14 +414,17 @@ function drawZombie(zb) {
// writing z_B1.png silently OVERWROTE the 'b' shambler's z_b1.png and a boss turned up in the crowd.
if (zb.variant === 'B') {
const A = zb.art || BOSS_ART[0];
const f = zb.hp / (zb.hpMax || 1), nStage = A.wounds.length;
const idx = f >= 1 ? 0 : Math.min(nStage, Math.max(1, Math.ceil((1 - f) * nStage)));
const idx = woundStage(zb, A.wounds.length); // the shared rule — see config.woundStage
// The stride steps off zb.dist — GROUND COVERED on BOTH axes, which zb.z alone is not: a boss
// that paces or hops sideways would slide across the court with its feet frozen. (zb.z is the
// fallback so a boss spawned by an older path still animates.)
const walked = (zb.dist ?? zb.z) / TUNE.bruteStride;
const wanted = zb.windT > 0 ? A.throws[Math.min(idx, A.throws.length - 1)]
: idx ? A.wounds[idx - 1]
: A.walk[stepFrame(zb.z / TUNE.bruteStride, 2)];
: A.walk[stepFrame(walked, 2)];
// a design whose sheet has not been generated (or has not loaded) degrades to design 1 rather
// than vanishing, since drawStatic silently draws nothing for a missing sprite
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(zb.z / TUNE.bruteStride, 2)];
const stage = SPR[wanted] ? wanted : BOSS_ART[0].walk[stepFrame(walked, 2)];
if (SPR[stage]) name = stage;
}
// Every frame is drawn at ITS OWN height. drawStatic forces the height it is given, so a crouched