Six new opponent families (head coach, stringer, strawberry seller, chair umpire, spectator, roller man) through the famroll/zsheet route, placed by hierarchy into an 18-rung ladder; the champion steps up behind them. THE DRAW: picking the tournament now rolls its own bracket in front of you — only the final was ever settled — and what lands is what walks on. Netplay, off Oliver's cellular report: the mid-flight early-decide was missing its !G.net gate, so the striker judged its own outbound flight and the two scoreboards drifted a point apart into a court nobody could serve on. The host's court now rides the room, its scoreboard rides the pretoss stream, and a called ball rolls to its own halt instead of freezing where the call caught it. Plus the night court, and somebody in the stand is taking pictures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
5.8 KiB
Python
104 lines
5.8 KiB
Python
# _heads.py — cut the CARD HEAD ICONS from the GORE-MODE bodies.
|
|
#
|
|
# uv run --with pillow --with numpy python assets/_heads.py # build all -> _refcheck/heads/
|
|
# uv run --with pillow --with numpy python assets/_heads.py --install # -> assets/z_<fam>head.png
|
|
#
|
|
# WHY THE HORDE SPRITE AND NOT THE FAMILY SHEET. The icon was first cropped from the installed
|
|
# family frames (config.HEAD_CROP — mush while those families were chunk), then cut from the 4K
|
|
# fam_ sheets. But the family art is the character drawn to PLAY TENNIS — optimised for movement,
|
|
# not for gore — and Oliver wants the card to lead with the zombie: "cut them from the art which is
|
|
# used in the splatter-gore mode, they are more zombie like there". Each family anchors on a horde
|
|
# body already (the same table _famroll.FAMS reads its character from), so the icon is cut from
|
|
# that sprite: the card's face and the monster on court are the same person, just the gorier print.
|
|
#
|
|
# NO RESIZE, NO REQUANTISE. drawCard scales the PNG to the card height itself with smoothing off
|
|
# and only reads the aspect, so the crop ships at native size — a straight window onto art that is
|
|
# already flat pixel art. Re-run after re-rolling any of the source bodies.
|
|
import os, sys
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
SCRATCH = os.path.join(HERE, '_refcheck')
|
|
|
|
# fam -> the gore-mode body it anchors on. KEEP IN SYNC with _famroll.FAMS (not imported: that
|
|
# module wants `requests` at import time, which the pillow/numpy env running this does not carry).
|
|
SRC = {'r': 'z_c1', # the club pro: the turned opponent IS him, and it is his goriest print
|
|
'br': 'z_g1', 'sh': 'z_a1', 'bb': 'z_e1', 'st': 'z_d1', 'ru': 'z_f1',
|
|
'gk': 'z_boss1', 'lj': 'z_judge1', 'dr': 'z_dir1', 'ch': 'z_champ1',
|
|
'ph': 'z_physio1', 'ma': 'z_mascot1',
|
|
'hc': 'z_coach1', 'sg': 'z_string1', 'sb': 'z_berry1', 'cu': 'z_chair1',
|
|
'sp': 'z_s1', 'rm': 'z_k1'}
|
|
# head depth as a fraction of the standing figure, where the shoulder-widening rule fails:
|
|
# the mascot's head IS partly the ball, and the ball is his identity — the wide crop is right.
|
|
DEPTH = {'ma': 0.37}
|
|
# ...and where NO top-down rule can work, a hand box (x0, y0, x1, y1 as fractions of the figure
|
|
# bbox): the keeper's ball launcher and the physio's stretcher ride ABOVE the head, the director
|
|
# holds a walkie-talkie level with his, the runner's lean fires the widening rule at his scalp,
|
|
# the judge's cap-and-glasses crop stopped above the mouth — and the brute's and champion's
|
|
# shoulders are so wide that a full-silhouette crop at head depth came out 2.2:1 and starved the
|
|
# card title of room (drawCard scales the icon by the PNG's aspect).
|
|
BOX = {'gk': (0.45, 0.14, 0.70, 0.44), 'dr': (0.20, 0.00, 0.55, 0.30),
|
|
'ph': (0.41, 0.02, 0.75, 0.33), 'ru': (0.25, 0.00, 0.80, 0.24),
|
|
'lj': (0.30, 0.00, 0.65, 0.32), 'br': (0.28, 0.00, 0.66, 0.30),
|
|
'ch': (0.32, 0.00, 0.66, 0.30),
|
|
# the 2026-08-15 six. EVERY ONE of them needed a hand box, and for the same reason each
|
|
# needed its prop talked out of the family roll: the thing they carry rides at head height.
|
|
# The stringer's coil, the seller's cap-and-tray, the umpire's chair, the spectator's ladder
|
|
# and the roller man's shoulder all fired the shoulder-widening rule inside the first few
|
|
# rows, and the automatic crop came back 88x18 / 36x17 / 35x16 — a letterbox of scalp.
|
|
'r': (0.40, 0.00, 0.82, 0.31),
|
|
'hc': (0.36, 0.00, 0.74, 0.26), 'sg': (0.42, 0.00, 0.72, 0.28),
|
|
'sb': (0.36, 0.00, 0.72, 0.30), 'cu': (0.36, 0.12, 0.92, 0.44),
|
|
# the spectator KEEPS his ladder in frame — it is behind and around his head in the only
|
|
# drawing of him there is, and a crop tight enough to lose it loses half his face too
|
|
'sp': (0.50, 0.21, 0.77, 0.47), 'rm': (0.10, 0.00, 0.40, 0.26)}
|
|
|
|
|
|
def head_box(a):
|
|
"""Head region of one keyed figure: top of the silhouette down to the shoulder widening."""
|
|
op = a[..., 3] > 0
|
|
ys = np.nonzero(op.any(1))[0]
|
|
y0, y1 = ys.min(), ys.max()
|
|
h = y1 - y0 + 1
|
|
width = op.sum(1)
|
|
med = np.median(width[y0:y0 + max(6, h // 16)][width[y0:y0 + max(6, h // 16)] > 0])
|
|
yS = None
|
|
for y in range(y0 + h // 20, y1):
|
|
if width[y] > 1.9 * med: yS = y; break
|
|
yS = yS or y0 + int(h * 0.2)
|
|
band = op[y0:yS]
|
|
xs = np.nonzero(band.any(0))[0]
|
|
pad = max(3, h // 60)
|
|
return (max(0, xs.min() - pad), max(0, y0 - pad), xs.max() + pad, yS + pad)
|
|
|
|
|
|
def build(install=False):
|
|
outdir = HERE if install else os.path.join(SCRATCH, 'heads')
|
|
os.makedirs(outdir, exist_ok=True)
|
|
for k, src in SRC.items():
|
|
a = np.array(Image.open(os.path.join(HERE, src + '.png')).convert('RGBA'))
|
|
op = a[..., 3] > 0
|
|
ys, xs = np.nonzero(op.any(1))[0], np.nonzero(op.any(0))[0]
|
|
fy, fx, fh, fw = ys.min(), xs.min(), ys.max() - ys.min() + 1, xs.max() - xs.min() + 1
|
|
if k in BOX: # a prop defeats every top-down rule here
|
|
x0f, y0f, x1f, y1f = BOX[k]
|
|
bx0, by0 = fx + int(fw * x0f), fy + int(fh * y0f)
|
|
bx1, by1 = fx + int(fw * x1f), fy + int(fh * y1f)
|
|
else:
|
|
bx0, by0, bx1, by1 = head_box(a)
|
|
if k in DEPTH: # hand depth beats the widening rule here
|
|
by1 = fy + int(fh * DEPTH[k])
|
|
band = op[by0:by1]
|
|
bxs = np.nonzero(band.any(0))[0]
|
|
bx0, bx1 = max(0, bxs.min() - 5), bxs.max() + 5
|
|
crop = a[by0:by1, bx0:bx1]
|
|
out = os.path.join(outdir, f'z_{k}head.png')
|
|
Image.fromarray(crop, 'RGBA').save(out)
|
|
print(f'{k}: {out} {crop.shape[1]}x{crop.shape[0]} (from {src})')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
build('--install' in sys.argv)
|