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

+1 -1
View File
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=189"></script>
<script type="module" src="src/main.js?v=190"></script>
</body>
</html>
+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
+21 -1
View File
@@ -677,6 +677,9 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1]
// pace scale on its returns' speed wide scale on how far off centre they may land
// flub scale on its whiff chance speed scale on how fast it covers the ground
// line the personality, said once, on the name plate as it walks on
// fam which SPRITE FAMILY this rung wears (config.ZFAMS). Absent = the club pro's body, 'r'.
// This is the payoff of _master.py --variant: generate an opponent, add its key to ZFAMS,
// name it on a rung, and that rung is a different body all the way down its wound ladder.
// short 8 characters, because that is the board's name slot — the match board names
// the thing you are playing instead of calling it THEM
const LADDER_TRAITS = [
@@ -695,7 +698,7 @@ const LADDER_TRAITS = [
// lands on rung 10 exactly as he lands on chapter 10 and the two rosters can never drift apart.
export const matchRung = n => {
const art = bossArt(n), i = BOSS_ART.indexOf(art);
return { name: art.name, key: 'L' + i, ...LADDER_TRAITS[i] };
return { name: art.name, key: 'L' + i, fam: 'r', ...LADDER_TRAITS[i] };
};
// ---- THE SPRITE BENCH's stops (back room only, see beats.startBench). Each is either an ANIMATED
@@ -718,6 +721,23 @@ export const BENCH = [
];
export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
// ---- MATCH-OPPONENT SPRITE FAMILIES. A family is a complete opponent: the eleven states at three
// wound stages, 31 frames, all named off one key (`r` -> z_r1, z_rtoss, z_rw1fall2 …). Generating a
// new one is `assets/_master.py --variant <key>`; USING it is one row here, because everything that
// needs the names — the preloader, drawZombie's wound lookup, the sprite bench — derives them from
// this table rather than spelling them out. That is the whole point: 31 hand-written filenames in a
// preload list is 31 chances to typo one and have a body silently vanish for a frame.
export const ZFAMS = ['r'];
const POSES = ['bounce', 'toss', 'serve', 'fall1', 'fall2', 'fall3', 'rise'];
export const famFrames = f => [
`z_${f}1`, `z_${f}2`, `z_${f}3`, `z_${f}4`, // stance, shuffle A, shuffle B, forehand
...POSES.map(s => `z_${f}${s}`),
// the wound rows carry no separate stance — the shuffle doubles as one, which is how drawZombie
// reads them (…w1 / …w1b are the stride pair, …w1s the forehand)
...[1, 2].flatMap(st => [`z_${f}w${st}`, `z_${f}w${st}b`, `z_${f}w${st}s`,
...POSES.map(s => `z_${f}w${st}${s}`)]),
];
// The roster, as the BODY COUNT screen reads it. Until now a variant's NAME lived only in the
// comment above its hp value — fine while nothing ever had to print one. The key is the `variant`
// char the zombie already carries, except that 'a' and 'b' fold together (they are one shambler in
+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() {