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>
150 lines
7.9 KiB
Python
150 lines
7.9 KiB
Python
# _zsheet.py — ONE generated multi-row sheet -> a whole zombie family of sprites.
|
|
#
|
|
# python3 assets/_zsheet.py assets/_refcheck/brute_all3.png br --out assets/_refcheck/sprites
|
|
# python3 assets/_zsheet.py assets/_refcheck/brute_all3.png br --install
|
|
#
|
|
# WHY THIS EXISTS. _regen.py generates a family as nine separate sheets, and nine separate rolls is
|
|
# nine chances for the character to drift — which is exactly what it did, all day. Asking for the
|
|
# WHOLE family in one image fixes that by construction: one roll, one character, one light, one
|
|
# palette. What was missing was the way back down — a generated sheet is 9 rows of figures and
|
|
# _fine.py cuts a single row. This is that step, and nothing else.
|
|
#
|
|
# TWO THINGS ARE GLOBAL, and both were bugs when they were not:
|
|
# SCALE _fine.py anchors one scale per sheet, so nine bands would each be scaled to their own
|
|
# tallest figure — the serve strike would come out the same height as the idle stance and
|
|
# every pose would pop, because the game sizes a sprite off its own PNG height. The scale
|
|
# is taken ONCE, from the standing frame of the play row, and every band uses it.
|
|
# PALETTE taken once from the largest figure in the whole sheet, so a wound row cannot quantise
|
|
# itself onto a slightly different set of colours than the healthy row it follows.
|
|
import os, sys
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from _fine import key, block_size, frames, split_piece # the proven keyer and column cutter
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
TARGET_H = 224
|
|
MIN_BAND = 40
|
|
|
|
# The family layout, exactly config.famFrames' order. The wound rows carry no separate stance —
|
|
# the shuffle doubles as one — which is why they are 3 and not 4.
|
|
def layout(k):
|
|
P = ['bounce', 'toss', 'serve']
|
|
F = ['fall1', 'fall2', 'fall3', 'rise']
|
|
rows = [[f'z_{k}1', f'z_{k}2', f'z_{k}3', f'z_{k}4'],
|
|
[f'z_{k}{s}' for s in P],
|
|
[f'z_{k}{s}' for s in F]]
|
|
for st in (1, 2):
|
|
rows += [[f'z_{k}w{st}', f'z_{k}w{st}b', f'z_{k}w{st}s'],
|
|
[f'z_{k}w{st}{s}' for s in P],
|
|
[f'z_{k}w{st}{s}' for s in F]]
|
|
return rows
|
|
|
|
|
|
def bands(a):
|
|
"""Row bands = runs of rows that contain ink. Rows are what a grid has and _fine.py refuses;
|
|
here they are the point, so they are found first and each band is handed over as its own row."""
|
|
rows = (a[..., 3] > 0).any(1)
|
|
out, s = [], None
|
|
for y, v in enumerate(rows):
|
|
if v and s is None: s = y
|
|
elif not v and s is not None:
|
|
if y - s >= MIN_BAND: out.append((s, y))
|
|
s = None
|
|
if s is not None and len(rows) - s >= MIN_BAND: out.append((s, len(rows)))
|
|
return out
|
|
|
|
|
|
def cut(sheet, fam, outdir):
|
|
want = layout(fam)
|
|
src = Image.open(sheet).convert('RGB')
|
|
a = key(src)
|
|
# THE ENCLOSED MAGENTA IS THE RACKET, AND HERE IT IS MEANT TO GO. _fine.py repaints magenta that
|
|
# is trapped inside a figure as pale grey, because the generator used to fill string beds with
|
|
# the backdrop colour BY ACCIDENT and keying them left men holding an empty hoop. This sheet is
|
|
# the opposite case: the prompt asks for the string bed to be exactly the background magenta so
|
|
# it cuts to transparency, and it obeyed. So every magenta pixel goes, connected to the border
|
|
# or not. That is safe only because nothing else on the figure is magenta — check a new sheet.
|
|
n = np.array(src).astype(int)
|
|
lo = np.minimum(n[..., 0], n[..., 2])
|
|
a[(lo > 140) & (n[..., 1] < lo - 55), 3] = 0
|
|
b = block_size(a)
|
|
bs = bands(a)
|
|
if len(bs) != len(want):
|
|
sys.exit(f'ERROR: found {len(bs)} row bands, expected {len(want)}')
|
|
|
|
crops = []
|
|
for (y0, y1), row in zip(bs, want):
|
|
band = a[y0:y1]
|
|
spans = frames(band, [], len(row))
|
|
if len(spans) != len(row):
|
|
sys.exit(f'ERROR: row {row[0]}: found {len(spans)} figures, expected {len(row)}')
|
|
for x0, x1 in spans:
|
|
sub = band[:, x0:x1]
|
|
ys = np.where((sub[..., 3] > 0).any(1))[0]
|
|
xs = np.where((sub[..., 3] > 0).any(0))[0]
|
|
c = sub[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
|
|
if split_piece(c):
|
|
sys.exit(f'ERROR: a frame in row {row[0]} holds two stacked bodies')
|
|
crops.append(c)
|
|
|
|
flat = [n for row in want for n in row]
|
|
# ONE scale, off the play row's standing frame; ONE palette, off the biggest figure anywhere.
|
|
k = TARGET_H / (crops[0].shape[0] / b)
|
|
big = max(crops, key=lambda c: (c[..., 3] > 0).sum())
|
|
px = big[big[..., 3] > 200][:, :3]
|
|
pal = np.unique((px // 8) * 8, axis=0)
|
|
|
|
# A FALL ROW IS ANCHORED ON ITS OWN BUCKLE, not on the sheet. The generator draws the fall rows
|
|
# noticeably smaller than the standing rows — the brute's buckle came back at 190 px against a
|
|
# 224 px stance — and one global scale preserves that faithfully, so the body SHRANK the moment
|
|
# it started to fall ("the fall animation seems shrunken"). The first frame of a fall is a body
|
|
# still on its feet, so it must draw at standing height; the other three are scaled by the same
|
|
# factor, which keeps the collapse's own proportions intact. A row that was already right (the
|
|
# racket family's, at 224/224) gets a factor of 1.0 and is untouched — which is the check that
|
|
# this rule is the right one rather than a fudge for one bad sheet.
|
|
mult = [1.0] * len(crops)
|
|
i = 0
|
|
for row in want:
|
|
if row[0].endswith('fall1') and crops[i].shape[0]:
|
|
k_row = crops[0].shape[0] / crops[i].shape[0]
|
|
for j in range(i, i + len(row)):
|
|
mult[j] = k_row
|
|
# A WALK CYCLE IS ONE DRAWING WITH THE LEGS MOVED, so its frames must be the SAME HEIGHT.
|
|
# The generator does not know that: the six new families came back with the shuffle frame
|
|
# 30-44 px shorter than the stance it walks out of, and the game draws every frame at its own
|
|
# height — so the body rose and dropped once per step. "the opponents are bouncing up and
|
|
# down on the court." The hand-made racket family spreads 9 px; that is what right looks like.
|
|
# Only the STANCE and the two shuffle frames are levelled; the forehand is a different pose
|
|
# and keeps its own height, exactly as the serve and the fall do.
|
|
elif not row[0].endswith('bounce'):
|
|
n = min(3, len(row)) # stance + stride pair (wound rows: the pair)
|
|
for j in range(i, i + n):
|
|
if crops[j].shape[0]:
|
|
mult[j] = crops[i].shape[0] / crops[j].shape[0]
|
|
i += len(row)
|
|
|
|
os.makedirs(outdir, exist_ok=True)
|
|
for c, name, m in zip(crops, flat, mult):
|
|
h, w = c.shape[:2]
|
|
snap = Image.fromarray(c, 'RGBA').resize((max(1, round(w / b)), max(1, round(h / b))), Image.BOX)
|
|
im = snap.resize((max(1, round(snap.width * k * m)), max(1, round(snap.height * k * m))), Image.BOX)
|
|
arr = np.array(im).astype(int)
|
|
solid = arr[..., 3] > 110
|
|
f = arr[..., :3].reshape(-1, 3)
|
|
arr[..., :3] = pal[((f[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1)].reshape(arr.shape[0], arr.shape[1], 3)
|
|
arr[..., 3] = np.where(solid, 255, 0)
|
|
Image.fromarray(arr.astype('uint8'), 'RGBA').save(os.path.join(outdir, name + '.png'))
|
|
print(f'{len(flat)} frames, art block {b}, scale {k:.3f}, palette {len(pal)} colours -> {outdir}')
|
|
for (y0, y1), row in zip(bs, want):
|
|
hs = [Image.open(os.path.join(outdir, n + '.png')).height for n in row]
|
|
print(f' {row[0]:14s} {len(row)} frames heights {hs}')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
|
out = HERE if '--install' in sys.argv else None
|
|
if '--out' in sys.argv: out = sys.argv[sys.argv.index('--out') + 1]
|
|
cut(args[0], args[1], out or os.path.join(HERE, '_refcheck', 'sprites'))
|