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>
245 lines
13 KiB
Python
245 lines
13 KiB
Python
# _famref.py — compose THE POSE REFERENCE, `assets/_pose_ref_family.png`, from the installed z_r*
|
|
# frames. Locally. No API call.
|
|
#
|
|
# uv run --with pillow --with numpy python assets/_famref.py # write it, then gate it
|
|
# uv run --with pillow --with numpy python assets/_famref.py --check # gate the file on disk only
|
|
#
|
|
# WHY THIS EXISTS. The reference every zombie tennis family is drawn from used to be a GENERATED
|
|
# RE-DRAW of the racket family: nine separate 4K calls, one per row band, stacked locally
|
|
# (`gen4k.py`, session "Zombie sprite regeneration"). Both of the faults Oliver found on the sprite
|
|
# bench came out of that round trip, and both were then copied faithfully into all six families:
|
|
#
|
|
# THE HEAD. The play row's call came back with the shuffle-A figure DECAPITATED. Measured against
|
|
# the sprite it was drawn from, that one figure scaled 2.341 wide and 1.996 tall where all thirty
|
|
# others were proportional to three decimals — 33 sprite px missing off the top, width untouched.
|
|
# The local input to that call (`in_1_play.png`) was exact at 2.000x. The sheet lost the head, not
|
|
# the sprite: `z_r2.png` on disk has always been fine.
|
|
#
|
|
# THE SCALE. Nine independent calls returned nine independent sizes — the fall bands at 81% of the
|
|
# standing bands, the w2 serve band at 87%. `_zsheet.py`'s fall1 re-anchor and its walk-frame
|
|
# levelling exist ONLY to undo that, and the levelling is what turned a headless z_sh2 into a
|
|
# headless AND 13% oversized one: it read the missing head as a height error and scaled up.
|
|
#
|
|
# A reference composed from the sprites cannot have either fault. One scale, applied once, NEAREST;
|
|
# every figure is the art that is already on disk. That is the whole fix, and it costs no tokens.
|
|
#
|
|
# THE GATE IS ON THE INPUT, WHICH IS WHERE IT WAS MISSING. `_zsheet.py` checks what comes BACK from
|
|
# the model — band count, figure count, stacked bodies, edges, palette, heights — and nothing ever
|
|
# checked the sheet handed TO it. check() below re-reads the file it just wrote with _zsheet's own
|
|
# detection and refuses a sheet the cutter would misread or that has lost a figure's head.
|
|
import os, sys
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from _zsheet import layout, bands, key # the same layout and the same row splitter
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
OUT = os.path.join(HERE, '_pose_ref_family.png')
|
|
FAM = 'r' # the club pro is the master. See PROJECT.md.
|
|
MAGENTA = (255, 0, 255)
|
|
SIZE = (3609, 6416) # 9:16, the aspect the images endpoint snaps to
|
|
MARGIN = 0.025 # a PROPORTION, never a pixel count — the model
|
|
GAP_X, GAP_Y = 60, 60 # picks its own resolution (see PROJECT.md)
|
|
STRIDE_MIN = 30.0 # % leg difference a real stride pair clears — calibrated in _stride.py
|
|
|
|
|
|
def bed_magenta(im):
|
|
"""Repaint the racket's string bed magenta, so it cuts to an open hoop downstream.
|
|
|
|
`_zsheet.py` deliberately INVERTS _fine.py's enclosed-magenta rule: the bed is meant to key out.
|
|
That only works if the reference SHOWS a magenta bed, because a picture beats a paragraph — the
|
|
prompt asking for one is not enough on its own. The installed z_r* frames have the bed painted
|
|
pale, so it is repainted here. The bed is the largest pale blob in the frame; the runners-up are
|
|
the shoes, at a third of its area or less on every one of the 31 frames.
|
|
"""
|
|
a = np.array(im.convert('RGBA')).astype(int)
|
|
R, G, B, A = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
|
|
lo, hi = np.minimum(np.minimum(R, G), B), np.maximum(np.maximum(R, G), B)
|
|
pale = (A > 40) & (lo > 140) & (hi - lo < 50)
|
|
lab = np.zeros(pale.shape, int)
|
|
n = 0
|
|
for y, x in zip(*np.nonzero(pale)):
|
|
if lab[y, x]: continue
|
|
n += 1
|
|
stack = [(y, x)]; lab[y, x] = n
|
|
while stack:
|
|
cy, cx = stack.pop()
|
|
for dy in (-1, 0, 1):
|
|
for dx in (-1, 0, 1):
|
|
ny, nx = cy + dy, cx + dx
|
|
if 0 <= ny < pale.shape[0] and 0 <= nx < pale.shape[1] and pale[ny, nx] and not lab[ny, nx]:
|
|
lab[ny, nx] = n; stack.append((ny, nx))
|
|
if not n: return im
|
|
areas = [(int((lab == i).sum()), i) for i in range(1, n + 1)]
|
|
best = max(areas)[1]
|
|
a[lab == best, :3] = MAGENTA
|
|
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.
|
|
|
|
Normalised so a wider canvas cannot fake a difference: same metres-per-pixel for every frame,
|
|
centred on the silhouette's own horizontal centre of mass, anchored on the ground line.
|
|
"""
|
|
a = np.array(im.convert('RGBA'))
|
|
op = a[..., 3] > 40
|
|
if not op.any(): return np.zeros((32, 64), bool)
|
|
h, w = op.shape
|
|
y0 = int(h * 0.66)
|
|
ys, xs = np.nonzero(op[y0:])
|
|
cx = xs.mean() if len(xs) else w / 2
|
|
W, H, mpp = 64, 32, w / 140
|
|
gx = np.clip((cx + (np.arange(W) - W / 2) * mpp).round().astype(int), 0, w - 1)
|
|
gy = np.clip((h - 1 - (H - 1 - np.arange(H)) * ((h - y0) / H)).round().astype(int), 0, h - 1)
|
|
return op[np.ix_(gy, gx)]
|
|
|
|
|
|
def stride_diff(a, b):
|
|
"""% of the union silhouette that differs. A real stride pair clears 33; a frozen one sits at 15."""
|
|
u = (a | b).sum()
|
|
return round(float((a ^ b).sum()) / u * 100, 1) if u else 0.0
|
|
|
|
|
|
def compose():
|
|
rows = layout(FAM)
|
|
frames = {n: bed_magenta(Image.open(os.path.join(HERE, n + '.png'))) for row in rows for n in row}
|
|
# ONE scale for all 31 figures, derived from whichever axis binds. This is the entire point of the
|
|
# file: the old sheet had five scales because it was five separate rolls.
|
|
row_w = [sum(frames[n].width for n in row) + GAP_X * (len(row) - 1) for row in rows]
|
|
row_h = [max(frames[n].height for n in row) for row in rows]
|
|
cw, ch = max(row_w), sum(row_h) + GAP_Y * (len(rows) - 1)
|
|
k = min(SIZE[0] * (1 - 2 * MARGIN) / cw, SIZE[1] * (1 - 2 * MARGIN) / ch)
|
|
|
|
sheet = Image.new('RGB', SIZE, MAGENTA)
|
|
y = round((SIZE[1] - ch * k) / 2)
|
|
for row, rh in zip(rows, row_h):
|
|
scaled = [(n, round(frames[n].width * k), round(frames[n].height * k)) for n in row]
|
|
x = round((SIZE[0] - (sum(w for _, w, _ in scaled) + round(GAP_X * k) * (len(row) - 1))) / 2)
|
|
ground = y + round(rh * k) # ONE ground line per band, bottom-aligned
|
|
for n, w, h in scaled:
|
|
sheet.paste(frames[n].resize((w, h), Image.NEAREST), (x, ground - h), frames[n].resize((w, h), Image.NEAREST))
|
|
x += w + round(GAP_X * k)
|
|
y = ground + round(GAP_Y * k)
|
|
sheet.save(OUT)
|
|
print(f'{OUT} {sheet.size} scale {k:.3f} 31 figures, {len(rows)} bands')
|
|
return k
|
|
|
|
|
|
def check():
|
|
"""Re-read the written sheet the way _zsheet.py will, and refuse anything it would misread."""
|
|
rows = layout(FAM)
|
|
a = key(Image.open(OUT).convert('RGB'))
|
|
bs = bands(a)
|
|
bad = []
|
|
if len(bs) != len(rows):
|
|
sys.exit(f'GATE FAILED: {len(bs)} row bands, expected {len(rows)}')
|
|
# scale is read off the stance, exactly as _zsheet.py reads it
|
|
K = None
|
|
for (y0, y1), row in zip(bs, rows):
|
|
band = a[y0:y1]
|
|
cols = (band[..., 3] > 0).any(0)
|
|
spans, s = [], None
|
|
for x, v in enumerate(cols):
|
|
if v and s is None: s = x
|
|
elif not v and s is not None:
|
|
if x - s > 20: spans.append((s, x))
|
|
s = None
|
|
if s is not None: spans.append((s, len(cols)))
|
|
if len(spans) != len(row):
|
|
sys.exit(f'GATE FAILED: row {row[0]}: {len(spans)} figures, expected {len(row)}')
|
|
for (x0, x1), n in zip(spans, row):
|
|
sub = band[:, x0:x1]
|
|
ys = np.nonzero((sub[..., 3] > 0).any(1))[0]
|
|
w, h = x1 - x0, int(ys.max() - ys.min() + 1)
|
|
src = Image.open(os.path.join(HERE, n + '.png'))
|
|
if K is None: K = h / src.height
|
|
sw, sh = w / src.width, h / src.height
|
|
# THE HEAD CLIP IS EXACTLY THIS TEST. A figure that lost its head keeps its width and
|
|
# loses height, so width and height stop agreeing — 2.341 against 1.996 is what the old
|
|
# sheet's z_r2 measured. Everything proportional to 2% is a figure that is all there.
|
|
if abs(sw - sh) > 0.02 * K:
|
|
bad.append(f'{n}: w{sw:.3f} h{sh:.3f} NOT PROPORTIONAL — a clipped figure')
|
|
elif abs(sh - K) > 0.02 * K:
|
|
bad.append(f'{n}: scale {sh:.3f} against the sheet\'s {K:.3f} — not one scale')
|
|
if bad:
|
|
sys.exit('GATE FAILED:\n ' + '\n '.join(bad))
|
|
print(f'gate: 31 figures, {len(bs)} bands, every one proportional at one scale {K:.3f}')
|
|
|
|
# ...and every stride pair, which the old sheet also got wrong. A frozen pair in the REFERENCE is
|
|
# a frozen pair in every family drawn off it — the model copies what it is shown, so this is the
|
|
# one place the check is worth anything. It was a warning while z_rw1b and z_rw2b still had the
|
|
# same legs as the frames they twin; `_stride.py` drew them, so it is a hard gate now.
|
|
L = {n: legs(Image.open(os.path.join(HERE, n + '.png'))) for n in
|
|
('z_r2', 'z_r3', 'z_rw1', 'z_rw1b', 'z_rw2', 'z_rw2b')}
|
|
frozen = []
|
|
for lbl, p, q in (('healthy 2<->3', 'z_r2', 'z_r3'), ('w1<->w1b', 'z_rw1', 'z_rw1b'),
|
|
('w2<->w2b', 'z_rw2', 'z_rw2b')):
|
|
d = stride_diff(L[p], L[q])
|
|
print(f' stride {lbl:14s} {d:5.1f}% {"ok" if d >= STRIDE_MIN else "FROZEN — legs barely move"}')
|
|
if d < STRIDE_MIN: frozen.append(f'{p}/{q} at {d:.1f}%')
|
|
if frozen:
|
|
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()
|
|
check()
|