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

+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);