Files
wuwiandClaude Opus 5 9cc3ac54b3 The dead club doubles its staff, and the draw is made where you can see it
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>
2026-08-15 11:49:19 +02:00

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)