The timing cues were match-only, and the walk cycles bounced

CLASSIC TENNIS, third time asked and this time actually found. Both cues open
with a mode gate and I checked the wrong two features twice: drawApproachRing
(the closing circles that show when to tap) and drawApexBand (the serve bar
with the gold perfect window) each began `if (!G.match ...)`. So the mode whose
whole pitch is "if the core tennis is good everything else is good" was the one
mode with neither. They run on `G.match || G.mode === 'tennis'` now — the gore
mode stays out on purpose, there is no rally there. A test reads both function
bodies and fails if either is ever gated on the match alone again.

(The two I kept pointing at — the landing ring and the placement ring — really
are shared. They were just not the ones being asked about.)

THE BOUNCING WAS IN THE ART, and measurable: the six generated families came
back with the shuffle frame 30-44 px shorter than the stance it walks out of,
against 9 px on the hand-made racket family. The game draws every frame at its
own PNG height, so the body rose and dropped once per step. A walk cycle is one
drawing with the legs moved and must be one height; _zsheet levels the stance
and the stride pair now and leaves the forehand its own, exactly as the serve
and the fall keep theirs. All seven families re-cut: spreads are 1-8 px.

THE BENCH GETS BUTTONS. Arrow keys are not a control scheme on a phone, which
is the device the walk has to be judged on — so every family and every wound
stage was unreachable from the place the problem shows. Three on-screen pads:
BODY, WOUND, POSE, the two-way ones split left/right. Geometry lives beside the
drawing and input hit-tests the same function, the rule the rack established.

THE FALL is quicker still (0.05/0.07/2.0/0.30): drop, lie there, get up.

Three umpire lines pulled — ump_wound_you, ump_felled, ump_wound_opp2. Not
funny enough to keep. Files deleted, call sites collapsed to the two that stay.

60 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-12 20:38:50 +02:00
1 parent e1ce2486c0
commit eb0e8775a4
46 files changed
+81 -8

No files matched your search

+34 -3
View File
@@ -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 = 104;
export const SPRV = 105;
// 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".
@@ -742,7 +742,11 @@ function drawZombie(zb) {
// the ring turns gold inside the band. The landing ring set this precedent: a marker that
// contracts toward its moment IS a timing cue, no legend needed.
function drawApproachRing() {
if (!G.match || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return;
// ...in CLASSIC TENNIS TOO. This was `!G.match`, so the mode whose entire pitch is "if the core
// tennis is good everything else is good" was the one mode without the timing cue that makes the
// core tennis legible. Reported three times before it was believed. The gore mode is left out on
// purpose: there is no rally there, the ball comes back off bodies.
if (!(G.match || G.mode === 'tennis') || G.phase !== 'rally' || !ball.active || ball.lastHit !== 'O') return;
const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); // the CONTACT test's own metric
if (d > TUNE.matchRingFar || ball.vz < 0) return; // far away, or already going back
const p = proj(ball.x, ball.y, ball.z);
@@ -762,7 +766,7 @@ function drawApproachRing() {
// zone for the playable one. A tick rides the live ball height. The ghost hand MIMES this moment;
// the band makes it a target you can see coming.
function drawApexBand() {
if (!G.match || G.phase !== 'toss' || score.server !== 'P') return;
if (!(G.match || G.mode === 'tennis') || G.phase !== 'toss' || score.server !== 'P') return;
const gx = ball.x + 0.55; // beside the toss, out of the ball's way
const apex = serveApexY();
const yTop = proj(gx, apex + 0.45, ball.z), yBot = proj(gx, 1.2, ball.z);
@@ -785,8 +789,35 @@ function drawApexBand() {
// 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.
// THE BENCH'S OWN BUTTONS. Arrow keys are not a control scheme on the device this game is played
// on, so every family and every wound stage was unreachable from a phone — which is where "the
// walking looks wrong on some of them" has to be checked. Geometry lives here, next to the drawing,
// and input.js hit-tests the same function: one source, so a moved button cannot stop responding
// (the rack learned this the hard way — see rackGeom).
export function benchButtons() {
const w = Math.floor((W - 24) / 3), h = 22, y = H - SAFE.bottom - h - 6;
return [
{ k: 'ArrowUp', k2: 'ArrowDown', label: 'BODY', x: 8, y, w, h },
{ k: 'ArrowLeft', k2: 'ArrowRight', label: 'WOUND', x: 8 + w + 4, y, w, h },
{ k: 'Space', k2: 'Space', label: 'POSE', x: 8 + (w + 4) * 2, y, w, h },
];
}
function drawBenchButtons() {
for (const b of benchButtons()) {
panel(b.x, b.y, b.w, b.h, 4, 'rgba(12,14,20,0.82)', UI.dim);
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = F(8);
// '<' and '>' on the two-way ones say the tap SIDE matters, which is the whole control:
// left half steps back, right half steps forward.
shadowText(b.k === b.k2 ? b.label : '\u2039 ' + b.label + ' \u203a',
b.x + b.w / 2, b.y + b.h / 2 + 1, UI.cream);
}
ctx.textBaseline = 'alphabetic';
}
function drawBenchHud() {
const b = G.bench; if (!b) return;
drawBenchButtons();
const zb = zombies.find(z => z.variant === 'r');
const y = CARD_TOP + 4;
ctx.textAlign = 'center';