diff --git a/index.html b/index.html index 10b3a1c..70a2f03 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 80474ad..d9e75df 100644 --- a/src/beats.js +++ b/src/beats.js @@ -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 at full health, // z_rw1 / z_rw2 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 diff --git a/src/config.js b/src/config.js index 3b6b9b3..440b293 100644 --- a/src/config.js +++ b/src/config.js @@ -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 `; 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 diff --git a/src/render.js b/src/render.js index 99ddd8a..8b99837 100644 --- a/src/render.js +++ b/src/render.js @@ -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 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() {