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

+21 -19
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 { BENCH, BENCH_STAGES, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { BENCH, BENCH_STAGES, ZFAMS, famFrames, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js';
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
@@ -195,7 +195,7 @@ export function proj(x, y, z) {
// Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the
// menu screens hang <img> tags on the same files, and two copies of this number is one copy too
// many: the one that does not get bumped is the one that serves a cached 404.
export const SPRV = 100;
export const SPRV = 101;
// The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was
// already requested at import — it just makes the wait visible and holds the menu back until the
// last sheet has landed. See the onerror note below for the other half of "sometimes missing".
@@ -241,17 +241,11 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
// THE MATCH OPPONENT, drawn to play tennis rather than to shamble: ready stance,
// shuffle pair, forehand — and the same three poses again at each wound stage
// (…s is the swing). Its own family, so the turned CPU above keeps its shamble.
'z_r1','z_r2','z_r3','z_r4',
'z_rw1','z_rw1b','z_rw1s','z_rw2','z_rw2b','z_rw2s',
// ...and the states the MODE still needs before it is a real tennis match: the
// serve (slice 7, alternating service) and the fall between points (slice 4).
// Loaded now so the sprite bench can show them the day they are drawn.
'z_rbounce','z_rtoss','z_rserve','z_rfall1','z_rfall2','z_rfall3','z_rrise',
// ...and the same seven at each wound stage: a one-armed opponent still has to serve
// and still has to go down, and reverting to the healthy body to do it would undo the
// damage the score just did
'z_rw1bounce','z_rw1toss','z_rw1serve','z_rw1fall1','z_rw1fall2','z_rw1fall3','z_rw1rise',
'z_rw2bounce','z_rw2toss','z_rw2serve','z_rw2fall1','z_rw2fall2','z_rw2fall3','z_rw2rise',
// EVERY MATCH-OPPONENT FAMILY, derived from config.ZFAMS — 31 names each, so a new
// opponent generated by _master.py --variant is ONE row in that table and not 31
// filenames typed out here (each of which is a chance to typo one and have a body
// vanish for a frame — the oldest bug in this file).
...ZFAMS.flatMap(famFrames),
'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons
'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel
'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS
@@ -582,8 +576,13 @@ function drawZombie(zb) {
// tennis points against you. The 'r' family is drawn off the LIVING opponent's poses instead (a
// ready stance, a shuffle pair and a real forehand, at every wound stage). 'c' keeps its shamble
// untouched, because in the gore mode shambling is exactly what the turned CPU should be doing.
const v = rk ? (SPR.z_r1 ? 'r' : 'c') : zb.variant; // 'c' until the new sheets have landed
const rNew = rk && v === 'r';
// WHICH FAMILY THIS BODY WEARS. `fam` rides on the zombie (spawnRacketZombie leaves it unset, so
// the club pro is 'r'), which is what lets a generated variant be a different opponent rather than
// a different game: every name below is built from it. Falls back to the turned CPU's shamble
// while a family's sheets have not landed.
const fam = zb.fam || 'r';
const v = rk ? (SPR['z_' + fam + '1'] ? fam : 'c') : zb.variant;
const rNew = rk && v !== 'c';
let N = ZFRAMES[v] || 2;
if (!SPR['z_' + v + N]) N = 2; // frames still loading (or sheet rejected): 2-frame fallback
// same CYCLE frequency as before — more frames subdivide the stride, they don't speed it up
@@ -643,7 +642,7 @@ function drawZombie(zb) {
// ...stepped off GROUND COVERED, the same clock the healthy cycle two blocks up uses. It was
// walkT — a wall clock — so a wounded opponent's legs cycled at a fixed cadence however fast
// it was actually moving, which is the foot-slide this project banned for the heavy bodies.
const base = 'z_rw' + st;
const base = 'z_' + fam + 'w' + st;
const want = zb.pose === 'swing' ? base + 's'
: zb.pose === 'run' && SPR[base + 'b'] && stepFrame((zb.dist || 0) / TUNE.racketStride, 2) ? base + 'b'
: base;
@@ -695,7 +694,7 @@ function drawZombie(zb) {
if (zb.showFrame) {
// a wound stage that has not been drawn yet degrades to the HEALTHY frame rather than to the
// walk cycle — the bench prints what it actually drew, so the substitution is visible
const heal = zb.showFrame.replace(/^z_rw\d/, 'z_r');
const heal = zb.showFrame.replace(new RegExp('^z_' + fam + 'w\\d'), 'z_' + fam);
if (SPR[zb.showFrame]) name = zb.showFrame;
else if (SPR[heal]) name = heal;
}
@@ -793,7 +792,8 @@ function drawBenchHud() {
const y = CARD_TOP + 4;
ctx.textAlign = 'center';
ctx.font = B(20);
shadowText(BENCH[b.i].name, W / 2, y, UI.cream);
shadowText((ZFAMS.length > 1 ? ZFAMS[b.fam].toUpperCase() + ' · ' : '') + BENCH[b.i].name,
W / 2, y, UI.cream);
ctx.font = F(8);
// ...and say so when the stop has no wound variant. The serve and the fall exist for the HEALTHY
// body only so far, so a stage label there would be claiming art that has not been drawn — and the
@@ -809,7 +809,9 @@ function drawBenchHud() {
shadowText(zb ? zb.drawn || '—' : 'NO BODY', W / 2, y + 40, UI.pick);
if (zb) shadowText('hp ' + zb.hp + '/' + zb.hpMax, W / 2, y + 54, UI.dim);
ctx.font = F(7, 0);
shadowText(IS_TOUCH ? 'TAP: NEXT STATE' : 'SPACE: NEXT STATE ←/→: WOUND STAGE', W / 2, H - 26, UI.dim);
shadowText(IS_TOUCH ? 'TAP: NEXT STATE'
: 'SPACE: NEXT STATE ←/→: WOUND STAGE' + (ZFAMS.length > 1 ? ' ↑/↓: OPPONENT' : ''),
W / 2, H - 26, UI.dim);
}
function drawDrillHud() {