The opponent can serve and fall, and there is a bench to judge it on

THE MISSING ART, generated by the whole-sheet restyle method: bouncing the ball
at the baseline, the toss with the ball leaving the fingertips, the strike at
full stretch — and the fall, which slice 4 has been waiting for: buckling, down
on the knees, flat on the court face down, then dragging itself back up. One
zombie is now complete as a tennis player rather than as a body that shuffles.
(Healthy only so far; the wound variants are the next sheet.)

THE SPRITE BENCH, in the back room. No ball, no scoring, no AI — one opponent
on an empty court, walking, at the size and speed it is actually played at,
because that is the only place a walk cycle can be judged. A sheet that looks
fine at 224 px on a contact sheet is a different animal at 30 px crossing a
lawn, and every sprite argument this project has had was really about that gap.

It steps through every state including the ones the game cannot reach yet, so
the serve and the fall could be looked at the day they were drawn instead of
the day they get wired up. It prints the FILENAME it is drawing and the hp the
wound stage was read from — the two numbers that separate "the art is wrong"
from "the wiring picked the wrong art" — and it says HEALTHY ONLY where a wound
variant does not exist rather than implying one does. The stride steps off
ground covered, like the real thing: a bench animating on a clock would flatter
exactly the sprites that foot-slide in play.

It owns the court like every other mode and every start* refuses while it runs.

FOUND BY IT, ten seconds in: `start()` applies the mode the setup screen picked,
so a remembered ZOMBIE TENNIS was taking the court before the back room could —
every back-room entry (weapons, bosses, drill, match, bench) had been silently
refusing since the mode select landed this morning. The back room passes null.

And a third calibration lesson: POSE MOVES A GLOBAL COLOUR STATISTIC. A body
face down shows a fraction of the shirt it shows standing, so `redness` fell 14
points across the fall sheet without a colour changing. The measure that
survives a pose change is the shorts HUE — mid-tone neutrals, present in every
frame in the same proportion — which is why it is the one that caught the drift
Oliver actually saw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 11:25:29 +02:00
1 parent ffc9cd3a60
commit 4bbdf6357a
16 files changed
+234 -30

No files matched your search

+49
View File
@@ -1284,5 +1284,54 @@
99,
4
]
},
"racket_serve": {
"sprites": [
"z_rbounce",
"z_rtoss",
"z_rserve"
],
"ref_only": [
"z_r1",
"o_bounce",
"o_toss",
"o_smash"
],
"state": true,
"check": "loose",
"anchor": 0,
"palette_ref": "z_r1",
"palette_tol": [
12,
10,
99,
4
],
"subject": "Three frames of the zombie SERVING. The FIRST reference image is the ZOMBIE TENNIS PLAYER this sheet is of — the same dead man, and he must be IDENTICAL to it in every frame: dirty yellow-blond hair, a RED CLOTH HEADBAND, a torn red short-sleeved tennis shirt, WARM CHARCOAL GREY tennis shorts above the knee (a slightly brownish dark grey, never blue-grey, never pale), bare grey-green dead legs, white socks and white tennis shoes, grey-green corpse skin, sunken eyes, a wooden racket with a pale strung head. Same build, same height, same colours, same drawing style. The REMAINING reference images are there ONLY to show the POSES to copy — never the character, never the clothes, never the skin. Copy each pose exactly. Frame 1: he stands at the baseline about to serve, bouncing the ball on the ground in front of him — copy reference image 2: upright, racket held low across the body, the free hand down with a tennis ball just under it. Frame 2: the TOSS — copy reference image 3: the free arm thrown STRAIGHT UP above the head with the ball leaving the fingertips, the racket dropping back behind the shoulder, weight rocking onto the back foot, the head tipped up watching the ball. Frame 3: the STRIKE — copy reference image 4: the racket at FULL STRETCH overhead at the moment of contact, the body extended upward on the toes, the free arm coming down across the chest. Output exactly 3 figures side by side in ONE horizontal row, all standing on the same ground line, with empty magenta between them. "
},
"racket_fall": {
"sprites": [
"z_rfall1",
"z_rfall2",
"z_rfall3",
"z_rrise"
],
"ref_only": [
"z_r1",
"p_die1",
"p_die2",
"p_die3"
],
"state": true,
"check": "loose",
"anchor": 0,
"palette_ref": "z_r1",
"palette_tol": [
20,
12,
99,
4
],
"subject": "Four frames of the zombie COLLAPSING AND GETTING BACK UP. The FIRST reference image is the ZOMBIE TENNIS PLAYER this sheet is of — the same dead man, and he must be IDENTICAL to it in every frame: dirty yellow-blond hair, a RED CLOTH HEADBAND, a torn red short-sleeved tennis shirt, WARM CHARCOAL GREY tennis shorts above the knee (a slightly brownish dark grey, never blue-grey, never pale), bare grey-green dead legs, white socks and white tennis shoes, grey-green corpse skin, sunken eyes, a wooden racket with a pale strung head. Same build, same height, same colours, same drawing style. The REMAINING reference images are there ONLY to show the POSES to copy — never the character, never the clothes, never the skin. Copy each pose exactly. Frame 1: the knees buckle — copy reference image 2: still upright but folding, one knee giving way, the racket arm flailing out for balance. Frame 2: DOWN ON HIS KNEES, doubled over, head low, one hand on the ground, still gripping the racket in the other — copy the crumpled posture of reference image 3. Frame 3: FLAT ON THE GROUND, face down, sprawled, the racket loose beside the outstretched hand — copy the collapsed posture of reference image 4. Draw him lying down: this figure is WIDE AND LOW, not standing. Frame 4: DRAGGING HIMSELF BACK UP — up on one knee with the other foot planted, one hand pushing off the ground, head coming up, the racket gripped again, halfway to standing. Output exactly 4 figures side by side in ONE horizontal row, all standing on the same ground line, with empty magenta between them. "
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+2 -1
View File
@@ -405,6 +405,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
<button class="btn big" id="scBosses">BOSS TEST</button>
<button class="btn big" id="scDrill">DRILL TEST</button>
<button class="btn big" id="scMatch">MATCH TEST</button>
<button class="btn big" id="scBench">SPRITE BENCH</button>
</div>
</div>
<!-- one tile per chapter boss, built from config.bossArt in main.js: adding a design to the
@@ -430,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
</div>
</div>
</div>
<script type="module" src="src/main.js?v=185"></script>
<script type="module" src="src/main.js?v=187"></script>
</body>
</html>
+84 -7
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, COURT, HOME } from './config.js';
import { TUNE, UI, bossArt, matchRung, BENCH, 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';
@@ -63,7 +63,7 @@ function startChangeover() {
export function startDuel() {
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
// another control mode owns the court. Each of these has ended a run when they overlapped.
if (G.interlude || G.horde || G.bossFight || G.drill || G.match) return;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
@@ -86,7 +86,7 @@ export function startDuel() {
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
// instant death. G.bossFight holds the chapter number while it runs.
export function startBossFight(chapter) {
if (G.bossFight || G.drill || G.match) return;
if (G.bossFight || G.drill || G.match || G.bench) return;
G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
@@ -135,7 +135,7 @@ export function endBossFight() {
// was walking around a moment ago is still walking around inside a drill round. The drill's own
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
export function startDrill() {
if (G.drill || G.interlude || G.horde || G.bossFight || G.match) return false;
if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench) return false;
G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null };
G.changeover = false; G.clearing = false; G.afterClear = null;
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
@@ -158,6 +158,83 @@ export function endDrill() {
ball.active = false; setupPoint();
}
// ---- THE SPRITE BENCH: the back room's art test, and nothing else. No ball, no scoring, no AI —
// ONE opponent on an empty court, at the size and the speed it is actually played at, because that
// is the only place a walk cycle can be judged. A sheet that looks fine at 224 px on a contact sheet
// is a different animal at 30 px moving across a lawn, and every sprite argument this project has
// had was really an argument about that gap.
//
// Each stop is either an ANIMATED state (the walk, driven by ground covered like the real thing) or
// ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be looked
// at the day they are generated rather than the day they are wired up.
const BENCH_HP = 12; // a deep pool so a wound stage is set by FRACTION, cleanly
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 };
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.
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
G.changeover = false; G.clearing = false; G.afterClear = null;
G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null;
ball.active = false; setupPoint();
G.flash = null; G.card = null; G.modal = null; G.modalQ.length = 0; // nothing the run was
// saying belongs over an art test — the bench's own
// readout lives in that band
spawnRacketZombie();
benchApply();
return true;
}
export function endBench() {
if (!G.bench) return;
G.bench = null;
zombies.length = 0;
ball.active = false; setupPoint();
}
// hp is set as a FRACTION of the pool, never as a literal, because that is how drawZombie reads a
// wound stage — the bench must ask the same question the game asks or it is testing something else.
function benchApply() {
const zb = zombies.find(z => z.variant === 'r'); if (!zb) return;
zb.hpMax = BENCH_HP;
zb.hp = Math.max(1, Math.round(BENCH_HP * [1, 0.5, 0.2][G.bench.stage]));
zb.showFrame = BENCH[G.bench.i].spr || null;
}
// the bench's own controls, taken before anything else reads the key: SPACE / a tap steps to the
// next state, left/right change the wound stage. One gesture, one meaning — the rule this project
// already holds itself to on touch.
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;
else b.i = (b.i + 1) % BENCH.length;
benchApply();
}
export function updateBench(dt) {
const b = G.bench; if (!b) return;
const zb = zombies.find(z => z.variant === 'r'); if (!zb) return;
const item = BENCH[b.i];
zb.z = 5.5; // parked on the far court, where an opponent stands
zb.swingCool = 1; zb.swingT = -1; zb.poseT = 0; // nothing expires a pose out from under the bench
if (item.walk) {
// THE STRIDE STEPS OFF GROUND COVERED, like the real one — a bench that animated on a clock
// would be flattering exactly the sprites that foot-slide in play.
const step = TUNE.racketSpeed * 0.55 * dt;
zb.x += b.dir * step;
zb.dist = (zb.dist || 0) + step;
if (Math.abs(zb.x) > 4.0) { b.dir *= -1; zb.x = Math.sign(zb.x) * 4.0; }
zb.face = b.dir;
zb.pose = 'run';
} else {
zb.pose = item.pose || 'idle';
zb.x = 0;
}
}
// ---- THE DEADLOCK WATCHDOG.
// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
@@ -217,7 +294,7 @@ export function watchdog(dt) {
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
// lands in matchGameWon via the empty-court check).
export function startMatch() {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match) return false;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
G.match = { opp: 1, ending: false };
G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore
// mode's 4 was "too weak" here, felt not calculated
@@ -376,7 +453,7 @@ export function resolveInterlude(side) {
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
// counts it down under the flash), and startHorde still owns the transition itself.
export function warnHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match) return false; // same court-ownership contract as the rest
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same court-ownership contract as the rest
G.hordeWarn = TUNE.hordeWarnT;
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
@@ -387,7 +464,7 @@ export function warnHorde() {
// Returns false when another mode owns the court — the caller re-arms the latch rather than
// silently eating the wave the set paid for.
export function startHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match) return false; // same contract as startDuel: one mode owns the court
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
+17
View File
@@ -698,6 +698,23 @@ export const matchRung = n => {
return { name: art.name, key: 'L' + i, ...LADDER_TRAITS[i] };
};
// ---- THE SPRITE BENCH's stops (back room only, see beats.startBench). Each is either an ANIMATED
// state or ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be
// looked at the day they are generated rather than the day they are wired up.
export const BENCH = [
{ name: 'WALK CYCLE', walk: true },
{ name: 'READY STANCE', pose: 'idle' },
{ name: 'FOREHAND', pose: 'swing' },
{ name: 'SERVE · BOUNCE', spr: 'z_rbounce' },
{ name: 'SERVE · TOSS', spr: 'z_rtoss' },
{ name: 'SERVE · STRIKE', spr: 'z_rserve' },
{ name: 'FALL 1 · BUCKLING', spr: 'z_rfall1' },
{ name: 'FALL 2 · KNEES', spr: 'z_rfall2' },
{ name: 'FALL 3 · DOWN', spr: 'z_rfall3' },
{ name: 'RISE', spr: 'z_rrise' },
];
export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS'];
// 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
+6 -2
View File
@@ -8,7 +8,7 @@ import { W } from './render.js'; // runtime-only cycle (render imports gameplay)
import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -1157,6 +1157,9 @@ function meleeCooldown() {
}
export function keyPressed(code) {
// THE SPRITE BENCH takes the key before anything else reads it: it is a back-room art test with no
// ball and no scoring, so every other meaning a key has here is meaningless inside it.
if (G.bench) return benchKey(code);
// Dead men toss no balls. The restart tap lives in main.js (maybeRestart); everything below —
// melee, parry, and especially the pretoss branch — must not run while the death animation plays,
// or a panic tap conjured a ball that hung frozen midair over the corpse (update early-returns
@@ -1477,6 +1480,7 @@ export function update(dt) {
// --- zombies + transition timeline
if (G.mode !== 'tennis') updateZombies(dt);
else updateParts(dt);
updateBench(dt); // the sprite bench's pacing body — a no-op unless the back room opened it
updateDrill(dt); // the coaching drill's round clock. It MOVES its bodies (they carry v = 0, so
// updateZombies' shamble skips them) and updateZombies still runs the contact
// and ball-hit tests against wherever this put them — that split is the design. // tennis has no zombies but its bounces still kick up dust that must settle
@@ -1545,7 +1549,7 @@ export function update(dt) {
// --- racket zombies rally from the far court (splatter only, between hordes)
if (G.mode === 'splatter' && !G.horde)
for (const rz of zombies) if (rz.variant === 'r') racketAI(rz, dt);
if (!G.bench) for (const rz of zombies) if (rz.variant === 'r') racketAI(rz, dt); // the bench drives its own body
// --- ball physics
if (ball.active) {
+22 -10
View File
@@ -6,7 +6,7 @@
import { TUNE, COURTS, TOURS, MODES, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js';
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch } from './beats.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench } from './beats.js';
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
@@ -161,7 +161,11 @@ function refreshSplash() {
summary();
}
function start() {
// `modeId` is the mode to drop into. It defaults to what the setup screen picked — but the BACK
// ROOM passes null, because those entries bring their own mode and a remembered ZOMBIE TENNIS was
// taking the court before startDrill/startBench/toggleTestMode could, so every one of them silently
// refused. The sprite bench found this the first time it was opened.
function start(modeId = pickedMode) {
if (!menuUp()) return;
selectTour(pickedTour);
selectCourt(picked);
@@ -171,8 +175,8 @@ function start() {
// differ by at this point: gore is the game as it always was, classic is the same tennis with the
// turn switched off, and zombie tennis is the match taking the court. Everything else about a mode
// lives in the code that reads G.classic / G.match, never here.
G.classic = pickedMode === 'classic';
if (pickedMode === 'zombie') { intoSplatter(); startMatch(); }
G.classic = modeId === 'classic';
if (modeId === 'zombie') { intoSplatter(); startMatch(); }
}
let stPeriod = 'all', stMode = stats.MODE_GORE;
// the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you
@@ -255,20 +259,27 @@ function intoSplatter() {
score.server = 'P'; setupPoint();
}
document.getElementById('scWeapons').addEventListener('click', () => {
start(); intoSplatter(); toggleTestMode();
start(null); intoSplatter(); toggleTestMode();
});
document.getElementById('scBosses').addEventListener('click', () => go('bosses'));
// THE DRILL, from the back room — the cognitive mode's feel-test. Like the two above it drops
// into a real run first (same loop, same contact rules) and then hands the court to startDrill,
// which refuses if anything else still owns it.
document.getElementById('scDrill').addEventListener('click', () => {
start(); intoSplatter();
start(null); intoSplatter();
startDrill();
});
document.getElementById('scMatch').addEventListener('click', () => {
start(); intoSplatter();
start(null); intoSplatter();
startMatch();
});
// THE SPRITE BENCH — the art test. Same "drop into a real run first" trick as its neighbours, so the
// body is drawn by the real renderer at the real size, on the real court, under the real palette.
// Judging a walk cycle anywhere else is judging a different picture.
document.getElementById('scBench').addEventListener('click', () => {
start(null); intoSplatter();
startBench();
});
// The list is built by ASKING bossArt which design each chapter gets, so it can never drift from
// what the run actually spawns — including the champion sitting on chapter 10 rather than 7.
@@ -278,7 +289,7 @@ for (let ch = 1; ch <= 10; ch++) {
b.innerHTML = `<img src="assets/${art.walk[0]}.png?v=${SPRV}" alt="">`
+ `<b>${art.name}</b><i>CHAPTER ${ch} · GAME ${ch * TUNE.bossEvery}</i>`;
b.addEventListener('click', () => {
start(); intoSplatter();
start(null); intoSplatter();
G.runGames = ch * TUNE.bossEvery; // the roster, the HUD and the spawn ramp all read this one
G.bossDue = 0; // ...and the schedule must not queue a SECOND fight behind it
for (const id of Object.keys(WEAPONS)) grantWeapon(id, TUNE.wCap); // full arsenal, every tin
@@ -440,8 +451,9 @@ function laserFire() {
// a plain timer, not the load event: this module runs as a module, so `load` may already have gone
if (location.hash === '#laser') setTimeout(laserScene, 600);
// #drill — the same door from a URL, so a phone can be sent straight into the mode
if (location.hash === '#drill') setTimeout(() => { start(); intoSplatter(); startDrill(); }, 700);
if (location.hash === '#match') setTimeout(() => { start(); intoSplatter(); startMatch(); }, 700);
if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter(); startDrill(); }, 700);
if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700);
if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700);
requestAnimationFrame(frame);
+50 -9
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_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, 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 = 96;
export const SPRV = 98;
// 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".
@@ -243,6 +243,10 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash','
// (…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',
'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
@@ -680,6 +684,10 @@ function drawZombie(zb) {
// project's oldest trap) is enough to cause it. Hold frame 1 instead: a body that stops animating
// is a bug you can see and diagnose; one that flickers in and out reads as the game breaking.
if (!SPR[name] && SPR['z_' + v + '1']) name = 'z_' + v + '1';
// THE BENCH OVERRIDE, last of all: it names a frame outright, so states the game cannot reach
// yet (the serve, the fall) can be looked at the day they are generated rather than the day they
// are wired up. Only the back room's sprite bench ever sets this.
if (zb.showFrame && SPR[zb.showFrame]) name = zb.showFrame;
// ...and only NOW the tour swap, so the height read below comes off the frame actually drawn. The
// branches above all test the men's names (which always exist), so the wound logic is untouched.
name = SK(name);
@@ -695,6 +703,8 @@ function drawZombie(zb) {
// Every side-on body is DRAWN facing left, so one walking right was moonwalking — obvious the
// moment they close on you across your own half. The front-facing ones (the turned CPU, the
// runner, the machine, a chapter boss) have no left or right to turn and are left alone.
zb.drawn = name; // WHICH FRAME THIS ACTUALLY IS. The sprite bench prints it, so a frame that looks
// wrong on the court names its own file instead of being hunted for afterwards.
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
@@ -763,6 +773,33 @@ function drawApexBand() {
// THE TRIAGE READOUT: score, multiplier, best — and the calibration marker. There is no strip and
// no called list any more; the court is the puzzle, so the HUD only carries what the court cannot
// say: how much the run is worth and how hot the streak is.
// THE SPRITE BENCH'S READOUT. The one thing a contact sheet cannot give you: what the game is
// ACTUALLY drawing, right now, at the size it plays at — including the filename, so a frame that
// looks wrong on the lawn names itself instead of being hunted for afterwards.
function drawBenchHud() {
const b = G.bench; if (!b) return;
const zb = zombies.find(z => z.variant === 'r');
const y = CARD_TOP + 4;
ctx.textAlign = 'center';
ctx.font = B(20);
shadowText(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
// whole reason this screen exists is to stop the art and the wiring lying to each other.
const named = !!BENCH[b.i].spr;
shadowText(named ? 'HEALTHY ONLY · NO WOUND VARIANT YET'
: BENCH_STAGES[b.stage] + ' · ' + (b.i + 1) + '/' + BENCH.length,
W / 2, y + 24, named ? UI.dim : UI.warm);
// the file on screen, and the hp fraction the wound stage was read from — the two numbers you
// need to tell "the art is wrong" from "the wiring picked the wrong art"
ctx.font = F(8);
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);
}
function drawDrillHud() {
const d = G.drill; if (!d) return;
const y = CARD_TOP + 4;
@@ -2059,6 +2096,8 @@ export function render() {
// top/left, dark bottom/right), a purple club spine, cream serif names, and the LIVE game
// sunk into a recessed slot — purple for the server, gold digits. Splatter adds a recessed
// status well (kills-to-next-point pips + weapon stock). Hard edges, no radius = retro.
const benchOn = !!G.bench; // an art test has no score; the board would be the biggest thing on
// screen arguing with the one frame you came here to look at
const splat = G.mode !== 'tennis';
// ZOMBIE TENNIS HAS ITS OWN BOARD. The club's bottle-green plate is the club's; the match is
// played on a board nobody has cleaned in a long time (assets/_board.py), and its ink has to
@@ -2077,7 +2116,8 @@ export function render() {
ctx.save(); ctx.translate(6, 6);
const bW = 150, bH = 42; // fixed: keeps the ornate PNG undistorted (art is 300x84 = 150x42)
const plate = zBoard || SPR.scoreboard;
if (plate) ctx.drawImage(plate, 0, 0, bW, bH); // generated ornate board backdrop
if (benchOn) { /* an art test has no score — not even the empty plate */ }
else if (plate) ctx.drawImage(plate, 0, 0, bW, bH); // generated ornate board backdrop
else { // code-drawn fallback until the PNG loads
R(0, 0, bW, bH, bd.face);
R(0, 0, bW, 1, bd.hi); R(0, 0, 1, bH, bd.hi);
@@ -2092,7 +2132,7 @@ export function render() {
const rung = G.match && zombies.find(z => z.variant === 'r' && z.rung);
const names = G.match ? ['YOU', (rung && rung.rung.short) || 'THEM']
: splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
for (let i = 0; i < 2; i++) {
for (let i = 0; i < 2 && !benchOn; i++) {
const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands
if (svr) { ctx.fillStyle = bd.gold; ctx.beginPath(); ctx.arc(11, cy, 1.8, 0, 7); ctx.fill(); } // server pip on the purple spine
ctx.textAlign = 'left'; ctx.font = F(8, svr); ctx.fillStyle = svr ? bd.cream : bd.dim;
@@ -2106,7 +2146,7 @@ export function render() {
ctx.font = F(8); ctx.fillStyle = bd.cream; ctx.fillText(String(score.games[i]), 90, cy); // games
ctx.font = F(8); ctx.fillStyle = svr ? bd.gold : bd.cream; ctx.fillText(ptTxt(i), 126, cy); // points, in the art's slot
}
if (splat || G.classic) {
if ((splat || G.classic) && !benchOn) {
// This row used to be bare text on the court. That was fine over one dark green lawn and is
// unreadable over a sunlit clay crowd or a floodlit blue one, so it gets its own panel — the
// same trick the scoreboard art above it already relies on.
@@ -2129,7 +2169,7 @@ export function render() {
}
// ...and NOT during a match: nothing on that court is killed for points, so the pips were both a
// lie and a collision — 'NEXT ○○' ran straight into the OPPONENT line sharing the panel.
if (splat && !G.match && !G.classic && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
if (splat && !G.match && !G.classic && !benchOn && TUNE.killPips && TUNE.killsPerPoint > 1) { // kills-to-next-point pips, on the panel above
ctx.textAlign = 'left'; ctx.font = F(7, 0); ctx.fillStyle = bd.dim; ctx.fillText('NEXT', 4, bH + 8);
for (let k = 0; k < TUNE.killsPerPoint; k++) {
ctx.beginPath(); ctx.arc(34 + k * 8, bH + 8, 2.4, 0, 7);
@@ -2139,9 +2179,10 @@ export function render() {
}
ctx.restore();
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
drawBlood(); // how many more hits you have left, beside the board — the vignette says "worse", not "how many"
if (G.mode === 'splatter' && !G.interlude && !G.drill) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
if (!G.bench) drawBlood(); // how many more hits you have left, beside the board — the vignette says "worse", not "how many"
if (G.mode === 'splatter' && !G.interlude && !G.drill && !G.bench) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
drawDrillHud(); // the triage readout — no-op unless a drill is running
drawBenchHud(); // ...and the sprite bench's, same rule
drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running
// THE END OF A RUN, whichever way it ended. Three modes end three different ways and they share
// one screen — a wash, a headline, the run's own two numbers, the mark to beat and the gated tap
@@ -2195,7 +2236,7 @@ export function render() {
: Math.max(G.changeover > 0 ? G.changeover : 0,
G.phase === 'over' ? G.overT : 0,
G.interlude ? G.interlude.introT : 0);
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot
if (G.phase === 'pretoss' && score.server === 'P' && G.mode !== 'eaten' && !G.horde && !G.bench && !(G.mgT > 0) && !G.aim && waitT <= 0.5) { // while pulling, the draw meter owns this slot
ctx.font = F(8);
const touch = IS_TOUCH, switchHint = touch ? 'TAP THE RACK TO SWITCH' : '1-0 TO SWITCH';
const selW = WEAPONS[slotId(G.weapon)], sel = selW ? selW.label : 'PLAIN';
+3
View File
@@ -115,6 +115,9 @@ export const G = {
classic: false,
done: null, // {won, t} — the run ENDED without a corpse (a classic match played out).
// The one end-of-run state that is not 'eaten': same summary, same gated tap.
bench: null, // THE SPRITE BENCH (back room only): {i, stage, dir} — one opponent on an
// empty court so a walk cycle can be judged at the size it is played at.
// Owns the court like every other mode; see beats.startBench.
drill: null, // the TRIAGE drill (src/drill.js): {t, spawnT, score, streak, kills, faults,
// lost, best, msg}. Owns the court like every other mode — see beats.startDrill.
// One rule: always kill the body that would reach you first. `t` is time
+1 -1
View File
@@ -966,7 +966,7 @@ export function updateZombies(dt) {
if (gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; }
if (G.mgT <= 0) { endBurst(); setupPoint(); } // window over: spend the tin, resume the serve loop
}
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight && !G.drill) { // breathers, duels, the mop-up after a won game, a boss fight AND a drill round all get a quiet court (no new spawns)
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight && !G.drill && !G.bench) { // breathers, duels, the mop-up after a won game, a boss fight, a drill round AND the sprite bench all get a quiet court (no new spawns)
spawnT -= dt;
// The spawn director: games finished sets both crowd size and tempo. Both curves used to hit a
// wall — the cap pinned at 14 by game 11 and the gap floored at 1.0 s by game 10, after which