diff --git a/assets/_famref.py b/assets/_famref.py index b922436..eeb3950 100644 --- a/assets/_famref.py +++ b/assets/_famref.py @@ -77,6 +77,39 @@ def bed_magenta(im): return Image.fromarray(a.astype('uint8'), 'RGBA') +def racket_side(im): + """Which side of the body the racket is drawn on, as a fraction of the frame width. + + The string bed is the marker and it locates itself: on the master's frames it is PAINTED (a flat + pale blob), on a cut family's it keys out to an enclosed hole. Either way it is the largest such + region above the shoes, and its centroid against the body's says which way the figure is drawn. + Returns +ve for racket-right, -ve for racket-left, or None when no bed can be found. + """ + a = np.array(im.convert('RGBA')).astype(int) + R, G, B, A = a[..., 0], a[..., 1], a[..., 2], a[..., 3] + h, w = A.shape + body = A > 40 + if not body.any(): return None + lo, hi = np.minimum(np.minimum(R, G), B), np.maximum(np.maximum(R, G), B) + upper = np.zeros_like(body); upper[:int(h * 0.72)] = True + pale = body & (lo > 140) & (hi - lo < 50) & upper + # the keyed-out case: transparent pixels NOT reachable from the border are the bed + seen = np.zeros_like(body) + stack = [(y, x) for x in range(w) for y in (0, h - 1) if not body[y, x]] + stack += [(y, x) for y in range(h) for x in (0, w - 1) if not body[y, x]] + for y, x in stack: seen[y, x] = True + while stack: + cy, cx = stack.pop() + for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)): + ny, nx = cy + dy, cx + dx + if 0 <= ny < h and 0 <= nx < w and not body[ny, nx] and not seen[ny, nx]: + seen[ny, nx] = True; stack.append((ny, nx)) + hole = (~body) & (~seen) & upper + bed = hole if hole.sum() >= 100 else pale + if bed.sum() < 60: return None + return float((np.nonzero(bed)[1].mean() - np.nonzero(body)[1].mean()) / w) + + def legs(im): """Feet-anchored, mass-centred silhouette of the bottom third — the stride comparison surface. @@ -183,6 +216,28 @@ def check(): sys.exit('GATE FAILED: frozen stride pair(s): ' + ', '.join(frozen) + f' — need >={STRIDE_MIN}%. Draw the twin with _stride.py before rolling a family.') + # ---- WHICH WAY EACH WALK FRAME IS DRAWN, pinned. render.js mirrors a body to face its direction + # of travel, and it can only do that if it knows which way the art faces. Today the healthy stride + # is drawn racket-RIGHT and the wound strides racket-LEFT — the wounded body faces the other way, + # and has since the master was made — so drawZombie carries a branch keyed on the frame name. + # THAT BRANCH IS ONLY CORRECT WHILE THIS HOLDS. A re-roll that mirrors a row would silently make + # every body in it walk backwards, which is exactly the bug Oliver found twice from the bench, so + # it fails here instead. If a side legitimately changes, fix drawZombie's `artFacesRight` with it. + want = {'z_r2': +1, 'z_r3': +1, 'z_rw1': -1, 'z_rw1b': -1, 'z_rw2': -1, 'z_rw2b': -1} + wrong = [] + for n, expect in want.items(): + r = racket_side(Image.open(os.path.join(HERE, n + '.png'))) + if r is None: + wrong.append(f'{n}: no racket bed found'); continue + got = 'RIGHT' if r > 0.06 else 'LEFT' if r < -0.06 else 'CENTRE' + ok = (r > 0.06) if expect > 0 else (r < -0.06) + print(f' racket {n:9s} {got:6s} {r:+.3f} {"ok" if ok else "CHANGED SIDE"}') + if not ok: + wrong.append(f'{n}: {got} ({r:+.3f}), expected {"RIGHT" if expect > 0 else "LEFT"}') + if wrong: + sys.exit('GATE FAILED: walk art changed facing: ' + '; '.join(wrong) + + "\n -> update drawZombie's artFacesRight test to match, then re-run.") + if __name__ == '__main__': if '--check' not in sys.argv: compose() diff --git a/index.html b/index.html index 4e8fc37..e8960fa 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 98f1bf6..23dba28 100644 --- a/src/beats.js +++ b/src/beats.js @@ -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; diff --git a/src/config.js b/src/config.js index 45da669..3943a04 100644 --- a/src/config.js +++ b/src/config.js @@ -8,6 +8,12 @@ export const TUNE = { bounceY: 0.58, bounceXZ: 0.78, playerSpeed: 5.4, aiSpeed: 4.3, reach: 1.3, aiReach: 1.6, // tight contact radius: the assist positions you, the tap must be honest + fallSlideDist: 0.5, // s of run the collapse travels: total slide = the body's speed x this + fallSlideMix: [0.10, 0.30, 0.45, 0.15], // how that total is shared across fall1/fall2/fall3/rise, so + // the landing SKIDS and the rise still moves. Tuned in #bench (FALL button). + proneSink: 0.20, // how far a fall3 frame drops, as a fraction of its own drawn height: + // its lowest ink is the RACKET, so bottom-aligning it floats the body + // (see the note at drawZombie's drawStatic call) swingWindup: 0.06, // s before contact window opens swingWindow: 0.16, // s contact window length // ALL FOUR PACE NUMBERS ARE 1.2x THEIR ORIGINALS (16/12 and 19/22) — the whole ball, tennis and diff --git a/src/gameplay.js b/src/gameplay.js index b752d6c..7dfbaec 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1083,8 +1083,14 @@ function racketAI(rz, dt) { rz.dist = (rz.dist || 0) + step; // ground covered — his walk cycle steps off this, not the clock rz.x += dx / d * step; rz.z += dz / d * step; rz.runT += dt; if (rz.pose !== 'swing') rz.pose = 'run'; - if (Math.abs(dx) > 0.2) rz.facing = dx > 0 ? 1 : -1; - } else if (rz.pose === 'run') rz.pose = 'idle'; + // `face`, NOT `facing`. Zombies render off zb.face (drawZombie); `facing` is the PLAYER's field + // (render flips a char on facing < 0 — the opposite convention, because the player art is drawn + // facing RIGHT and the bodies are drawn facing LEFT). This line wrote a field nothing reads for a + // zombie, so the match opponent's turn was left to the generic sticky-face line in updateZombies. + // vx is kept so a body that is knocked down mid-chase can carry the run into its collapse. + if (Math.abs(dx) > 0.2) rz.face = dx > 0 ? 1 : -1; + rz.vx = dx / d * step / dt; + } else { rz.vx = 0; if (rz.pose === 'run') rz.pose = 'idle'; } if (rz.swingCool <= 0 && rz.swingT < 0 && Math.hypot(ball.x - rz.x, (ball.z - rz.z) * 0.8) < TUNE.aiReach && ball.y < CONTACT.maxY && ball.y > CONTACT.minY) { diff --git a/src/main.js b/src/main.js index a7e546f..50e535d 100644 --- a/src/main.js +++ b/src/main.js @@ -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, startBench, endBench } from './beats.js'; +import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall } 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'; @@ -465,5 +465,5 @@ requestAnimationFrame(frame); // exposed for in-browser sanity checks and simulated playtests window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, dragServe, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, - stats, step: dt => update(dt), render, press: keyPressed, + stats, step: dt => update(dt), render, press: keyPressed, FALL, benchFall, get phase() { return G.phase; }, set phase(v) { G.phase = v; } }; diff --git a/src/render.js b/src/render.js index 9431940..009a750 100644 --- a/src/render.js +++ b/src/render.js @@ -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() {