The corpse was levitating, and the wounded ones walked backwards

All of it found from the sprite bench, and most of it was the same mistake
twice: a correction applied on top of art that already handled it.

THE HEADLESS BODY SHRANK. "Every headless mode shrinks the whole opponent a
little" — measured, 34.0 px healthy / 33.8 arm off / 27.8 headless on all EIGHT
families, a flat -18%, and the FEET shrank 16-19% with it. A body that loses its
head does not get smaller feet. drawZombie carried `if (st === 2) h = 1.52` to
take a head off the top, and a headless sprite is still a FULL-HEIGHT frame,
because the generator draws a gushing neck stump whose blood plume fills the
space the head occupied: 18.3% of z_rw2's frame sits above its shoulders against
10.7% above z_rw1's head. So the art had taken the head off, the per-frame PNG
scaling had handled the height, and the override took it off a third time.
Wrong on the fallback skins too (z_cw2 is a SHORTER png, which the scaling
handles): its body drew at 1.30 m against the healthy 1.78 m. Gone. The HP bar
and the aim mark read config.zombieH directly, so they had been floating above a
shrunken body for as long as it existed; they re-align for free.

THE WOUNDED BODIES WALKED BACKWARDS, and this is not bench-only — racketAI and
updateBench both set face as the sign of x movement, so a match did it too. The
racket is what measures it, Oliver's own test: "if this racket is facing to the
right I would assume that he also walks to the right." Its string bed keys out
to an enclosed hole (or is painted pale on the master), so the hoop locates
itself. The healthy stride is drawn racket-RIGHT (+0.38..+0.42) and every wound
stride racket-LEFT (-0.39..-0.45): THE WOUNDED BODY IS DRAWN FACING THE OTHER
WAY, on the master and therefore in the reference and therefore in all seven
families. One flip rule cannot serve both rows — fixing the healthy walk
inverted the wound walk, which is the second thing he saw. The flip is derived
from the frame name now, and only for the WALK frames (w1/w1b/w2/w2b), because
they are the only ones whose orientation tracks travel: the wound forehand
measures racket-right like the healthy one, and a serve or a fall puts the
racket overhead where it says nothing about direction. Checked against 18 frame
names including the tour-suffixed ones.

AND _famref.py PINS THOSE SIDES. That branch is only correct while the art stays
this way, and this is the THIRD art inconsistency this session that reached the
court because nothing measured it. A re-roll that mirrors a row now fails the
reference build pointing at drawZombie's artFacesRight, instead of shipping.

A DEAD WRITE, found on the way: racketAI set rz.facing, and zombies render off
zb.face. `facing` is the PLAYER's field and uses the OPPOSITE convention (a char
flips on facing < 0, a body on face > 0), so the match opponent's turn was never
driven by its chase at all — it fell through to the generic sticky-face line.

THE PRONE FRAME LAY ON ITS RACKET. "The player basically levitates over the
ground held by his racket." Every frame is bottom-aligned on the ground point,
and in the fall3 art the lowest ink is the racket hoop thrown out beside the
body — z_shw1fall3 is 249x85 with the body's underside at y67 and rows 68-84
racket, so the torso floated a fifth of the frame up. TUNE.proneSink drops it by
that fraction, putting the body line where fall2 leaves its knees. The shadow
made it worse and is suppressed for the whole beat: the contact ellipse is sized
off DRAWN WIDTH, and a prone frame is three times wider than a standing one, so
the widest darkest shadow in the game was painted under the one body not
standing on it.

THE FALL CARRIES THE RUN. "When the opponent runs to the ball from left to right
and then falls, the player must still move to the right while falling." The
velocity is INHERITED (racketAI and updateBench both record zb.vx), so a body
that went down standing still does not slide and one caught at a sprint slides
furthest. It was exponential decay first, which put 93% of the travel in the
first two frames — "the visible slide should also extend to the 3rd and 4th
frame" — and no retuning fixes that: stretch a wall-clock decay far enough to
move the prone body and it creeps for the whole floor hold. So the slide is a
DISTANCE BUDGET shared across the frames (TUNE.fallSlideMix), eased out within
each so it still decelerates. x is set absolutely from the start, so the total
cannot drift with frame rate. 0.05 / 0.22 / 0.35 / 0.11 m — the landing skids
and the rise drags itself the last of the way.

THE BENCH GOT A FALL BUTTON to tune all of this, and it plays the REAL beat:
same FALL table, same tickMatchFall, because a bench that re-implemented the
timing would be refining a second copy of it. matchReady() is skipped while the
bench owns the court (it runs setupPoint and re-places the body — a match doing
match things inside an art test), the walk stop stops walking during a fall, and
startFall reads the table LIVE via fallLen() so tuning the numbers cannot leave
the 'over' beat expiring mid-collapse.

FALL is 0.06 / 0.25 / 2.0 / 0.45 after a session in the bench. fall1 and fall2
are deliberately the same pace (0.87 and 0.88 m/s of slide at a 1.54 m/s entry)
so the collapse reads as one movement decelerating into the ground; the 0.07 s
fall2 it replaced gave each drawn frame ~4 display frames and read as a cut.

THE LENS, portrait camZ 48 -> 49. The three camera numbers are not independent:
holding the near player's drawn size fixed means camF = s_player * (camZ - zMax),
holding the court's on-screen depth fixed means camY = depth / (s_player -
camF/camZ), so 49 gives 795 and 11.23 and horizon re-derives to 43. Far opponent
33.1 -> 33.8 px, player unchanged, court band within a pixel. One metre is
deliberate: at camZ 55 the umpire is already clipped off the right edge and by 70
the court reads top-down, and it narrows the player's lateral room because
xVisible clamps him to what fits. A figScale experiment that scaled every body
1.2x got further (far body 40.6 px) but is NOT in here — it needs the vertical
contact constants to scale with it (ball-vs-body is gated on ball.y < 1.9, which
sits just above the tallest chest at 1.856 m, so the roller man would have gone
invulnerable) and Oliver reverted it pending a real playtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-13 07:32:17 +02:00
1 parent e121df569c
commit f1af5c9a72
7 files changed
+238 -29

No files matched your search

+71 -10
View File
@@ -209,6 +209,17 @@ function benchApply() {
zb.showFrame = suf ? 'z_' + f + (G.bench.stage ? 'w' + G.bench.stage : '') + suf : null;
}
// THE FALL BUTTON PLAYS THE REAL BEAT. Same FALL table, same tickMatchFall — the bench must never
// re-implement a timing it exists to judge, or it would be refining a second copy of it. `from` is the
// stage the body collapses IN; the rise reads racketStage(zb) off the hp the bench already set, so the
// sequence wears the wound stage currently on screen. Press it again to replay.
export function benchFall() {
if (!G.bench || G.matchFall) return false;
const zb = zombies.find(z => z.variant === 'r'); if (!zb) return false;
G.matchFall = { t: 0, from: G.bench.stage, vx: zb.vx || 0, x0: zb.x };
return true;
}
// 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.
@@ -219,6 +230,9 @@ export function benchKey(code) {
// 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;
// F plays the fall. Before the fallback below, which treats ANY other key as "next state" — a new
// control that forgets to land above that line silently steps the stop instead.
else if (code === 'KeyF') return benchFall();
else b.i = (b.i + 1) % BENCH.length;
benchApply();
}
@@ -229,18 +243,22 @@ export function updateBench(dt) {
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
// ...and a body that is COLLAPSING is not pacing. tickMatchFall owns the frame while the fall runs,
// so the walk stop must stop walking or the corpse slides sideways through its own death.
if (G.matchFall) return;
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.vx = b.dir * step / dt; // so a FALL from here inherits the run, as in a match
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;
zb.x = 0; zb.vx = 0;
}
}
@@ -366,13 +384,25 @@ function matchReady() {
// stumble; the beat only lands if it is down long enough to look beaten.
// "fall quicker. lie there for 2s then stand up." The collapse is now a drop — two frames in a fifth
// of a second — and the time is spent on the floor, where it reads.
const FALL = [['fall1', 0.05], ['fall2', 0.07], ['fall3', 2.0], ['rise', 0.30]];
export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0);
// EXPORTED AND MUTABLE, so the sprite bench's FALL button can be tuned against the art while it
// plays — refining these four numbers IS the job that button exists for, and a bench that could not
// reach them would only prove the beat runs, not that it feels right.
// TUNED IN #bench WITH THE FALL BUTTON, watching it against the art rather than reasoning about it.
// fall1 and fall2 are deliberately the SAME PACE (0.87 and 0.88 m/s of slide at a 1.54 m/s entry), so
// the collapse reads as one continuous movement decelerating into the ground; the 0.07 s fall2 it
// replaced gave the two drawn frames ~4 display frames each and read as a cut, and a 0.40 s one split
// them 0.80 against 0.56 m/s, which is a visible hitch between two poses of the same drop.
export const FALL = [['fall1', 0.06], ['fall2', 0.25], ['fall3', 2.0], ['rise', 0.45]];
// Read LIVE by startFall, because a snapshot goes stale the moment the table is tuned and the match's
// 'over' beat would then expire mid-collapse — the exact bug the comment above is about.
export const fallLen = () => FALL.reduce((s, f) => s + f[1], 0);
export const FALL_LEN = fallLen(); // the load-time value test.js imports as a loop bound
function startFall(from) {
G.matchFall = { t: 0, from };
const fz = zombies.find(z => z.variant === 'r');
G.matchFall = { t: 0, from, vx: (fz && fz.vx) || 0, x0: (fz && fz.x) || 0 };
ball.active = false;
G.phase = 'over'; G.overT = FALL_LEN + 0.6; // outlives the fall on purpose — see above
G.phase = 'over'; G.overT = fallLen() + 0.6; // outlives the fall on purpose — see above
}
// Ticked from update(), beside the horde's warning, for the same reason it is. It also drives the
@@ -382,17 +412,48 @@ export function tickMatchFall(dt) {
if (!G.matchFall) return tickMatchServe(dt);
const f = G.matchFall;
const zb = zombies.find(z => z.variant === 'r');
if (!zb) { G.matchFall = null; return matchReady(); } // felled by something else mid-fall
// THE BENCH OWNS THE COURT WHEN IT IS OPEN, so neither exit hands a point back: matchReady() runs
// setupPoint() and re-places the body on its receiving mark, which is a MATCH doing match things
// inside an art test. Same one-mode-owns-the-court rule the start* functions all obey.
const done = () => { G.matchFall = null; if (zb) zb.showFrame = null; if (!G.bench) matchReady(); };
if (!zb) { G.matchFall = null; if (!G.bench) matchReady(); return; } // felled by something else mid-fall
f.t += dt;
let acc = 0, cur = null;
for (const [suf, len] of FALL) { acc += len; if (f.t < acc) { cur = suf; break; } }
if (!cur) { // back on its feet: hand the point back
G.matchFall = null; zb.showFrame = null;
return matchReady();
}
if (!cur) return done(); // back on its feet: hand the point back
// ...and this is the line the beat is for: the collapse wears the OLD stage, the rise wears the
// NEW one. drawZombie's showFrame override degrades a missing stage to the healthy frame, so a
// family whose fall rows have not been drawn yet still plays the beat instead of blinking out.
// A BODY RUNNING FOR THE BALL DOES NOT STOP DEAD TO FALL OVER. It carries the run into the collapse
// and skids out of it — Oliver: "when the opponent runs to the ball from left to right and then
// falls, the player must still move to the right while falling." The velocity is INHERITED (racketAI
// and updateBench both record zb.vx), so a body that went down standing still does not slide at all
// and one caught at a sprint slides furthest.
//
// THE SLIDE IS A DISTANCE BUDGET SHARED OUT ACROSS THE FRAMES, not a decaying velocity. It was
// exponential decay first, and that put 93% of the travel in the first two frames: "the visible
// slide should also extend to the 3rd and 4th frame, between those the slide is barely noticeable."
// A wall-clock decay cannot fix that by retuning — stretch it far enough to move the prone body and
// it creeps for the whole 1.8 s floor hold, which is a corpse on ice. So each frame gets an explicit
// SHARE of the total (TUNE.fallSlideMix), eased out within its own segment so it still decelerates:
// the drop carries, the landing skids, and the rise drags itself the last of the way.
// x is set ABSOLUTELY from the start position rather than integrated, so the total cannot drift with
// frame rate and the same fall lands in the same place every time.
if (f.vx) {
const mix = TUNE.fallSlideMix, total = f.vx * TUNE.fallSlideDist;
let acc2 = 0, seg = 0, segStart = 0;
for (let i = 0; i < FALL.length; i++) { // which segment, and how far into it
if (f.t < acc2 + FALL[i][1]) { seg = i; segStart = acc2; break; }
acc2 += FALL[i][1]; seg = i; segStart = acc2 - FALL[i][1];
}
const len = FALL[seg][1] || 1e-6;
const u = Math.max(0, Math.min(1, (f.t - segStart) / len));
const ease = 1 - (1 - u) * (1 - u); // ease-out: quick, then settling
let done2 = 0;
for (let i = 0; i < seg; i++) done2 += mix[i] || 0;
const travelled = total * (done2 + (mix[seg] || 0) * ease);
zb.x = Math.max(-COURT.dblW / 2, Math.min(COURT.dblW / 2, f.x0 + travelled));
}
const st = cur === 'rise' ? racketStage(zb) : f.from;
zb.showFrame = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '') + cur;
zb.pose = 'idle'; zb.swingT = -1;