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

+95 -14
View File
@@ -144,9 +144,20 @@ export function fitViewport() { // landscape 480x270; portrait 270 wide and as T
// overlap each other less at depth) and `horizon` re-centres the court after both.
// Net: far shambler 26 -> 30 logical px, the player unchanged, the court occupying the same band.
// If you retune these, check all four: far size, near size, court top, court bottom.
TUNE.camZ = portrait ? 48 : 42;
TUNE.camY = portrait ? 11 : 9;
TUNE.camF = portrait ? 760 : 406;
// PORTRAIT WENT 48 -> 49. THE THREE NUMBERS ARE NOT INDEPENDENT, which is what the "check all
// four" note above is really about: holding the near player's drawn size fixed means
// camF = s_player * (camZ - P_BOUNDS.zMax), and holding the court's on-screen depth fixed means
// camY = depth / (s_player - camF/camZ). So 49 gives 795 and 11.23, and `horizon` below
// re-derives itself and drops 51 -> 43. Net: the far opponent 33.1 -> 33.9 logical px, the player
// unchanged at 64.9, the court band within a pixel of where it was.
// ONE METRE IS DELIBERATE. Pushing the lens further does keep growing the far body, but it spends
// the court to do it — measured at camZ 55 the umpire is already clipped off the right edge, and
// by 70 the sidelines run near-parallel and the whole thing reads top-down instead of like a
// broadcast camera. It also narrows the player's lateral room, because xVisible() clamps him to
// what fits on screen and a wider court fits less.
TUNE.camZ = portrait ? 49 : 42;
TUNE.camY = portrait ? 11.23 : 9;
TUNE.camF = portrait ? 795 : 406;
// THE COURT RIDES HIGH ENOUGH TO PULL UNDER HIM. A full draw is TUNE.pullFull CSS px of thumb
// travel STARTING at his feet, and it has to finish clear of the rack and of the iOS bottom
// edge — where a swipe down is Reachability and the system takes the gesture out from under
@@ -458,14 +469,17 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) {
}
// ----------------------------------------------------------------- actors ----
function drawStatic(name, x, z, hMeters, bob = 0, flip = false) {
// `o.sink` lowers the frame by a fraction of its own drawn height, and `o.shadow: false` drops the
// contact ellipse. Both exist for the PRONE frame of a fall — see the note at drawZombie's call.
function drawStatic(name, x, z, hMeters, bob = 0, flip = false, o = {}) {
const img = SPR[name]; if (!img) return;
const p = proj(x, 0, z);
const h = hMeters * p.s, w = h * img.width / img.height;
drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
if (!flip) return ctx.drawImage(img, p.x - w / 2, p.y - h - bob, w, h);
const y = p.y - h - bob + h * (o.sink || 0);
if (o.shadow !== false) drawShadow(p.x, p.y, w * 0.4, h * 0.04, 0.25);
if (!flip) return ctx.drawImage(img, p.x - w / 2, y, w, h);
ctx.save(); ctx.translate(p.x, 0); ctx.scale(-1, 1); // the art is drawn facing LEFT
ctx.drawImage(img, -w / 2, p.y - h - bob, w, h);
ctx.drawImage(img, -w / 2, y, w, h);
ctx.restore();
}
function drawBoy(b) {
@@ -634,7 +648,24 @@ function drawZombie(zb) {
// boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors.
else if (rk) {
const st = racketStage(zb); // config's rule — the fall beat reads the same one
if (st === 2) h = 1.52; // headless: a head shorter, so the shoulders land where a whole zombie's do
// NO HEIGHT OVERRIDE FOR THE HEADLESS STAGE. There used to be `if (st === 2) h = 1.52` here, to
// take a head off the top — and it was double-counting, because the ART already accounts for the
// missing head and the line above already scales every frame by its own PNG height. Oliver, from
// the bench: "every headless mode shrinks the whole opponent a little." Measured: the drawn body
// went 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.
// Why the correction went stale: 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 the frame sits above z_rw2's shoulders against 10.7% above z_rw1's head. Shrinking the
// whole frame on top of that scaled the torso and the legs too. It was also wrong for the
// borrowed 'c' fallback skins (z_cw2 is a SHORTER PNG, which the per-frame scaling handles):
// their body drew at 1.30 m against the healthy 1.78 m.
// Residual: the plume is not exactly a head, so the headless body still lands within about -8% to
// +2% of the wounded one, varying by family (r -8.5%, gk +1.8%). That is art variance, not a
// code-imposed shrink, and the place to fix it is _zsheet.py — levelling the wound rows on the
// SHOULDER line, the same way it already levels the walk frames on total height.
// NOTE the HP bar and the aim mark read config.zombieH directly, so they were floating above a
// shrunken body for as long as this override existed; removing it re-aligns them for free.
if (st && rNew) {
// every wound stage carries the SAME three poses as the healthy body, so a one-armed
// opponent still plays tennis rather than reverting to a shamble the moment you wound it
@@ -722,7 +753,49 @@ function drawZombie(zb) {
// 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));
// THE PRONE FRAME LIES ON ITS RACKET, NOT ON THE GROUND. 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 —
// so the torso hangs above the line and the corpse reads as levitating, propped on its own racket.
// Oliver, from the bench: "the player basically levitates over the ground held by his racket."
// Measured on z_shw1fall3 (249x85): the body's underside is at y67 and rows 68-84 are the racket,
// so the body floats a fifth of the frame's height. TUNE.proneSink drops it by that fraction, which
// puts the body line where fall2 leaves its knees; the racket then lies across the line, which is
// where a dropped racket belongs. The suffix-tolerant test is for the tour swap (SK appends to the
// name, so a plain /fall3$/ would miss z_..fall3_f).
// ...AND NO CONTACT SHADOW WHILE IT FALLS. The ellipse is sized off the DRAWN WIDTH, and a prone
// frame is three times wider than a standing one — so the widest, darkest shadow in the game was
// being painted under the one body that is not standing on it, which is most of why it read as
// floating. Suppressed for the whole beat, not just the prone frame: a body already off its feet
// has no business casting a standing man's contact patch.
// THE TWO ART GENERATIONS FACE OPPOSITE WAYS, so they flip on opposite signs. The old shamblers are
// drawn facing LEFT — one walking right is mirrored, which is what `face > 0` is for. THE MATCH-
// OPPONENT FAMILIES ARE DRAWN FACING RIGHT, and were being mirrored by the same test, so every one
// of them walked backwards: on court and in the bench alike, since racketAI and updateBench both set
// face as the sign of x movement.
// MEASURED, not guessed, and the racket is what measures it. Its string bed keys out to an enclosed
// transparent hole, so the hoop locates itself: in all seven families, in BOTH walk frames, the
// hoop's x centroid sits ~50 px to the RIGHT of the body's (sh 101 vs 53, lj 103 vs 52, br 102
// vs 48). Oliver's own test — "if this racket is facing to the right I would assume that he also
// walks to the right" — and a racket thrown out to the right is a body heading right.
// The front-facing bodies ('c' turned CPU, 'f' sprinter, 'm' machine, 'B' boss) are excluded from
// flipping entirely: mirroring a body with no left or right only swaps which hand holds the racket.
// ...AND THE WOUND WALK FRAMES ARE MIRRORED RELATIVE TO THE HEALTHY ONES. Measured the same way,
// on the master and on every family: healthy stride +0.38..+0.42 (racket right), wound stride
// -0.39..-0.45 (racket left). The wounded body is drawn facing the OTHER WAY, and it has been since
// the master was made, so the reference carries it and all seven families copied it. One flip rule
// therefore cannot serve both rows — fixing the healthy walk (which is where Oliver first saw it)
// inverted the wound walk, which is what he saw next.
// The test is the WALK FRAMES ONLY (`w1`, `w1b`, `w2`, `w2b`), because they are the only frames whose
// orientation tracks travel. The wound FOREHAND (`w1s`) measures racket-right like the healthy one,
// and the serve/fall poses put the racket overhead or flailing where it says nothing about direction
// — so they stay on the healthy convention rather than being dragged along by their row.
// `_famref.py` pins these sides, so a future roll that mirrors a row fails the gate instead of
// silently reaching the court. If that gate ever fires, fix the ART and delete this branch.
const prone = /fall3(_|$)/.test(name);
const artFacesRight = !rNew || !/w\db?(_[a-z0-9]+)?$/.test(name);
const flip = (artFacesRight ? zb.face < 0 : zb.face > 0) && !'cfmB'.includes(v);
drawStatic(name, zb.x, zb.z, h, bob, flip,
{ sink: prone ? TUNE.proneSink : 0, shadow: !G.matchFall });
if (zb.burnT > 0) drawFlames(zb, h);
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.bossWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the design's own wind
@@ -803,11 +876,14 @@ function drawApexBand() {
// 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;
// FOUR across now: FALL plays the real collapse-and-rise beat (beats.benchFall), which is a SEQUENCE
// and not a pose, so it could not be one of the stepped stops.
const w = Math.floor((W - 28) / 4), 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: '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 },
{ k: 'KeyF', k2: 'KeyF', label: 'FALL', x: 8 + (w + 4) * 3, y, w, h },
];
}
@@ -847,9 +923,14 @@ 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' + (ZFAMS.length > 1 ? ' ↑/↓: OPPONENT' : ''),
W / 2, H - 26, UI.dim);
// WHILE THE FALL RUNS, the hint line becomes the beat's stopwatch — refining the pause means
// watching a number next to the picture, not counting frames afterwards. The frame name two lines
// up already says WHICH picture, so this only has to say how long it has been up.
const mf = G.matchFall;
shadowText(mf ? 'FALL ' + mf.t.toFixed(2) + 's · TAP FALL AGAIN TO REPLAY'
: IS_TOUCH ? 'TAP: NEXT STATE · FALL: THE REAL COLLAPSE + RISE'
: 'SPACE: NEXT STATE ←/→: WOUND STAGE' + (ZFAMS.length > 1 ? ' ↑/↓: OPPONENT' : '') + ' F: FALL',
W / 2, H - 26, mf ? UI.warm : UI.dim);
}
function drawDrillHud() {