Files
splatter-tennis/assets/_zsheet.py
T
wuwiandClaude Opus 5 5f9170b7d7 The friend across the net wears your kit, drawn from the front
His colour landed on the shirt while yours lands on the shorts, because he was
drawn on the roster opponent's red sheets and dyed on an arc of his own. Red is
the one hue that art cannot hang a kit on: the men's shorts are neutral grey at
saturation 0.05, so no rotation ever reached them, and the women's red top sits
at her skin's and her hair's hue — a dark pick was painting her arms and her
ponytail, live, on the shipped build.

So he gets his own sheets. `assets/_frontroll.py` is `_famroll`'s trick pointed
at the tennis poses — image 1 is the ten `o_*` frames composed from disk, image 2
is the player from behind — and two calls drew all twenty. He rides the PLAYER's
arcs and the tour's own mode now: his shorts dye, her whites wash. `TINT.oKit` is
gone. The roster CPU keeps the red art; it is not a person.

The setup screen shows that front too — picking a face off the back of a head was
only ever what the art allowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:04:10 +02:00

159 lines
8.5 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=None, level=None):
# `want`/`level` let a NON-zombie family reuse this cutter: the netplay opponent's ten tennis
# poses are not nine rows of walk/wound (see _frontroll.py). `level` names the frame groups that
# must come back the same height, which is what the zombie branch below works out by name.
want = want or 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)
if level is not None:
at = {n: j for j, n in enumerate(flat)}
for grp in level:
h0 = crops[at[grp[0]]].shape[0]
for n in grp[1:]:
if crops[at[n]].shape[0]: mult[at[n]] = h0 / crops[at[n]].shape[0]
i = 0
for row in want if level is None else []:
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'))