diff --git a/assets/_heads.py b/assets/_heads.py index df46211..98a3ecc 100644 --- a/assets/_heads.py +++ b/assets/_heads.py @@ -1,32 +1,45 @@ -# _heads.py — cut the CARD HEAD ICONS from the 4K family sheets. +# _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_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_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. +# 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 -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'] +# 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 = {'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'} # 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} +# 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)} def head_box(a): @@ -50,45 +63,26 @@ def head_box(a): 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) + 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(arr.astype('uint8'), 'RGBA').save(out) - print(f'{k}: {out} {arr.shape[1]}x{arr.shape[0]}') + Image.fromarray(crop, 'RGBA').save(out) + print(f'{k}: {out} {crop.shape[1]}x{crop.shape[0]} (from {src})') if __name__ == '__main__': diff --git a/assets/z_bbhead.png b/assets/z_bbhead.png index a67e743..b2374f7 100644 Binary files a/assets/z_bbhead.png and b/assets/z_bbhead.png differ diff --git a/assets/z_brhead.png b/assets/z_brhead.png index a1a84fe..61192ae 100644 Binary files a/assets/z_brhead.png and b/assets/z_brhead.png differ diff --git a/assets/z_chhead.png b/assets/z_chhead.png index 7844c26..2ad747b 100644 Binary files a/assets/z_chhead.png and b/assets/z_chhead.png differ diff --git a/assets/z_drhead.png b/assets/z_drhead.png index 5ad731d..352799a 100644 Binary files a/assets/z_drhead.png and b/assets/z_drhead.png differ diff --git a/assets/z_gkhead.png b/assets/z_gkhead.png index 9da677b..71040ed 100644 Binary files a/assets/z_gkhead.png and b/assets/z_gkhead.png differ diff --git a/assets/z_ljhead.png b/assets/z_ljhead.png index e6ea1b6..8105887 100644 Binary files a/assets/z_ljhead.png and b/assets/z_ljhead.png differ diff --git a/assets/z_mahead.png b/assets/z_mahead.png index 20dcdb6..d79bf87 100644 Binary files a/assets/z_mahead.png and b/assets/z_mahead.png differ diff --git a/assets/z_phhead.png b/assets/z_phhead.png index d8df2c6..bb7d325 100644 Binary files a/assets/z_phhead.png and b/assets/z_phhead.png differ diff --git a/assets/z_ruhead.png b/assets/z_ruhead.png index 6abda72..e73de25 100644 Binary files a/assets/z_ruhead.png and b/assets/z_ruhead.png differ diff --git a/assets/z_shhead.png b/assets/z_shhead.png index c0992d0..6cc0c05 100644 Binary files a/assets/z_shhead.png and b/assets/z_shhead.png differ diff --git a/assets/z_sthead.png b/assets/z_sthead.png index 7de817c..390b437 100644 Binary files a/assets/z_sthead.png and b/assets/z_sthead.png differ diff --git a/index.html b/index.html index 078f770..467e677 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/render.js b/src/render.js index c6ebdd9..827c860 100644 --- a/src/render.js +++ b/src/render.js @@ -221,7 +221,7 @@ function farB(z) { // Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the // menu screens hang tags on the same files, and two copies of this number is one copy too // many: the one that does not get bumped is the one that serves a cached 404. -export const SPRV = 113; +export const SPRV = 114; // The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was // already requested at import — it just makes the wait visible and holds the menu back until the // last sheet has landed. See the onerror note below for the other half of "sometimes missing".