A match opponent is a FAMILY now, so a generated variant is one row and not dead art

--variant produces 31 frames; nothing could draw them. drawZombie hardcoded the
`z_r` prefix, the preloader spelled all 31 filenames out by hand, and the sprite
bench built its names from the same literal. A new zombie would have sat on disk.

  config.ZFAMS = ['r']        the families that exist
  config.famFrames(f)         the 31 names derived from a key

Everything that needs those names now derives them: the preloader spreads
ZFAMS.flatMap(famFrames), drawZombie reads `zb.fam` (unset = 'r', so the club
pro is untouched) and builds its wound lookups off it, and the bench walks
families on up/down. Adding an opponent is ONE ROW — which also retires 31
hand-typed filenames from the preload list, each of which was a chance to typo
one and have a body silently vanish for a frame. That is the oldest bug in
render.js and it is now impossible in this corner of it.

And the link that makes it worth anything: a LADDER RUNG can name a family.
`fam: 'br'` on a row in LADDER_TRAITS and that rung walks on as a different
body, all the way down its own wound ladder. So the whole path is now:

  _master.py --variant br "…"   ->  generate 31 frames
  ZFAMS += 'br'                 ->  the game loads them
  fam: 'br' on a ladder row     ->  that opponent is in the match

Verified: the bench still walks all 33 cells pulling the right derived name at
every wound stage, and a real match still opens on THE GROUNDSKEEPER wearing
family 'r' and drawing z_r1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 13:04:06 +02:00
1 parent 4bec0cb09c
commit e2487b784a
4 files changed
+52 -24

No files matched your search

+9 -3
View File
@@ -7,7 +7,7 @@
// (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, matchRung, BENCH, COURT, HOME } from './config.js';
import { TUNE, UI, bossArt, matchRung, BENCH, ZFAMS, COURT, HOME } from './config.js';
import { G, P, O, ball, score, setupPoint, sendBoy } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
@@ -171,7 +171,7 @@ const BENCH_HP = 12; // a deep pool so a wound stage is se
export function startBench() {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
G.bench = { i: 0, stage: 0, dir: 1 };
G.bench = { i: 0, stage: 0, fam: 0, dir: 1 };
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0;
// ...and DISARM the ordinary schedule, exactly as the drill does: a pending horde or an owed
// chapter is a latch that keeps trying to fire behind a mode that has no bodies for it.
@@ -203,8 +203,10 @@ function benchApply() {
// ...and the named states take the wound stage with them: z_r<suffix> at full health,
// z_rw1<suffix> / z_rw2<suffix> below it. render falls back to the healthy frame if a stage has
// not been drawn yet, and the HUD says so — a bench must never imply art that does not exist.
const f = ZFAMS[G.bench.fam] || 'r';
zb.fam = f;
const suf = BENCH[G.bench.i].spr;
zb.showFrame = suf ? (G.bench.stage ? 'z_rw' + G.bench.stage : 'z_r') + suf : null;
zb.showFrame = suf ? 'z_' + f + (G.bench.stage ? 'w' + G.bench.stage : '') + suf : null;
}
// the bench's own controls, taken before anything else reads the key: SPACE / a tap steps to the
@@ -214,6 +216,9 @@ export function benchKey(code) {
const b = G.bench;
if (code === 'ArrowLeft' || code === 'KeyA') b.stage = (b.stage + 2) % 3;
else if (code === 'ArrowRight' || code === 'KeyD') b.stage = (b.stage + 1) % 3;
// up/down walks the OPPONENT FAMILIES — one today, one row in config.ZFAMS per variant after that
else if (code === 'ArrowUp' || code === 'KeyW') b.fam = (b.fam + ZFAMS.length - 1) % ZFAMS.length;
else if (code === 'ArrowDown' || code === 'KeyS') b.fam = (b.fam + 1) % ZFAMS.length;
else b.i = (b.i + 1) % BENCH.length;
benchApply();
}
@@ -343,6 +348,7 @@ function matchOpponent() {
// pure data, read where the dials are already read (see matchRung in config). It rides on the
// BODY so that killZombie can file the kill under it without asking G what rung we were on.
zb.rung = matchRung(G.match.opp);
zb.fam = zb.rung.fam; // ...and the body that rung wears (config.ZFAMS)
G.card = { title: zb.rung.name, sub1: zb.rung.line, color: UI.hot, t: 2.4 };
zb.hp = zb.hpMax = TUNE.matchOppHp; // a FLAT pool: with regicide gone the body is pure
// scoreboard (a quarter per point), so a deeper pool would