Exact touchdown, the boy finishes his shift, healing announces itself, 4K heads

Five of Oliver's six (the sixth is a play-mode discussion):
- THE DOUBLE FAULTS WERE FRAME QUANTIZATION: the bounce registered a full
  integration step after true contact — ~0.75 m of travel at serve pace — so
  serves aimed half a metre safe were judged long. Classic enforces the
  service box, the match only the court, hence 'zombie tennis does not'.
  The bounce now rewinds to y=0 before anything reads the position.
  Measured in-pane: 30/40 faults on good taps before, 0/40 after (mistimed
  and smudged taps also 0/40; aimed hard serves keep their scatter risk).
  Also: the strike flick dead zone is 44 px so a smudged tap stays a tap,
  and the aim base clamps 0.18 m inside the lines — scatter, not the aim,
  is what faults.
- The mop beat holds the walk-on until the boy actually LEAVES the court
  (ordered off at beat end, 6 s cap).
- A match game win that restores blood shows the duel's WOUNDS TREATED
  popup; full-blood wins stay silent.
- The opponent walk-on card holds 4.0 s (was 2.4).
- Card heads are cut from the 4K sheets at 144 px (assets/_heads.py,
  z_<fam>head.png, HEAD_CROP stays as fallback) — the old 65 px crops were
  the 'very low res' heads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-13 21:09:04 +02:00
1 parent c847bcbfa1
commit c6218a6284
17 files changed
+178 -23

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
# _heads.py — cut the CARD HEAD ICONS from the 4K family sheets.
#
# 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 walk-on card first cropped heads out of the INSTALLED 224 px frames (config.HEAD_CROP):
# a ~65 px source head drawn at 144 device px is mush, and the oldest families (sh/bb/st/ru) were
# the mushiest — "the first heads in the roster are very low res". The 4K sheets in _refcheck have
# the same heads at ~4x the pixels, so the icon is cut THERE and downscaled once, cleanly.
# drawCard prefers z_<fam>head and degrades to the HEAD_CROP crop, so a family whose 4K sheet is
# gone (they are gitignored scratch) simply keeps the old icon. Re-run after re-rolling a family.
import os, sys
import numpy as np
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _fine
from _zsheet import bands
HERE = os.path.dirname(os.path.abspath(__file__))
SCRATCH = os.path.join(HERE, '_refcheck')
OUT_H = 144 # the card draws the icon at CARD_H-14 = 48 logical px; RES caps at 3 -> 144 device
FAMS = ['br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma']
# head depth as a fraction of the standing figure, where the shoulder-widening rule fails:
# the brute's shoulders start high, the keeper's hat brim is wider than his face, the champion
# wears a headwrap, and the mascot's head IS partly the ball. Same knowledge as HEAD_CROP's note.
DEPTH = {'br': 0.27, 'gk': 0.30, 'ch': 0.28, 'ma': 0.37}
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 in FAMS:
sheet = os.path.join(SCRATCH, f'fam_{k}.png')
if not os.path.exists(sheet):
print(f'{k}: no 4K sheet — the card keeps its HEAD_CROP fallback'); continue
a = _fine.key(Image.open(sheet))
bs = bands(a)
band = a[bs[0][0]:bs[0][1]]
cols = (band[..., 3] > 0).any(0)
x0 = np.nonzero(cols)[0][0] # first figure of the play row
gap = 0; x1 = x0
for x in range(x0, len(cols)):
gap = gap + 1 if not cols[x] else 0
if gap > 30: x1 = x - gap; break
else: x1 = len(cols)
fig = band[:, x0:x1]
ys = np.nonzero((fig[..., 3] > 0).any(1))[0]
fh = ys.max() - ys.min() + 1
bx0, by0, bx1, by1 = head_box(fig)
if k in DEPTH: # hand depth beats the widening rule here
by1 = ys.min() + int(fh * DEPTH[k])
band2 = (fig[..., 3] > 0)[by0:by1]
xs = np.nonzero(band2.any(0))[0]
bx0, bx1 = max(0, xs.min() - 5), xs.max() + 5
crop = fig[by0:by1, bx0:bx1]
im = Image.fromarray(crop, 'RGBA')
s = OUT_H / im.height
im = im.resize((max(1, round(im.width * s)), OUT_H), Image.BOX)
# quantise to the crop's own palette so the icon stays flat pixel art, not a soft photo
arr = np.array(im).astype(int)
solid = arr[..., 3] > 110
px = crop[crop[..., 3] > 200][:, :3]
pal = np.unique((px // 8) * 8, axis=0)
flat = arr[..., :3].reshape(-1, 3)
idx = ((flat[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1)
arr[..., :3] = pal[idx].reshape(arr.shape[0], arr.shape[1], 3)
arr[..., 3] = np.where(solid, 255, 0)
out = os.path.join(outdir, f'z_{k}head.png')
Image.fromarray(arr.astype('uint8'), 'RGBA').save(out)
print(f'{k}: {out} {arr.shape[1]}x{arr.shape[0]}')
if __name__ == '__main__':
build('--install' in sys.argv)
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB