Files
splatter-tennis/assets/_famroll.py
T
wuwiandClaude Opus 5 8cef111277 The groundskeeper stops wearing a hat nobody drew him
His family sheet was rolled against a description that disagreed with every picture
of him — "stained work overalls and a shapeless hat" against a green club blazer and
braces — so the 31 frames averaged the two and came out a third character. The card
portrait and the boss on court have been different men ever since.

He now anchors on his own throw frame redrawn at the grain the other bosses ship at
(density 0.34 against the legacy 0.21, tracked as _ref_gk_hi so the roll stays
reproducible), and the description names what the art actually shows. The hopper
basket is called out as gone: a prop at head height is the one thing a 31-figure
sheet cannot hold.

Rolled through _famroll, cut with _zsheet. Density 0.26 -> 0.36 across the family.
First roll came back with a lilac panel over the top rows and was thrown away — the
cutter keys the corner, so that panel would have shipped as opaque background.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:42:40 +02:00

219 lines
15 KiB
Python

# _famroll.py — roll ONE opponent family: 31 sprites, one call, one character.
#
# uv run --with pillow --with requests python assets/_famroll.py br # one family
# uv run --with pillow --with requests python assets/_famroll.py sh bb # several, in sequence
#
# Then cut it back down, review first and install second:
# uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_br.png br --out assets/_refcheck/sprites_br
# uv run --with pillow --with numpy python assets/_zsheet.py assets/_refcheck/fam_br.png br --install
#
# WHY THIS IS IN THE REPO. Every family in ZFAMS except the club pro was rolled by a script that lived
# in a session scratchpad and is gone, off a reference in a gitignored directory. The pipeline PROJECT.md
# calls the one reliable way to make this game's art was, in practice, unreproducible. This is that
# script, tracked, reading the tracked reference that `_famref.py` composes.
#
# IMAGE 1 IS THE POSES, IMAGE 2 IS THE CHARACTER, and naming each image's job in the prompt is
# load-bearing: without it the majority wins and the roll comes back as whatever most of the pixels
# look like. See PROJECT.md, "What the model will and will not do".
import base64, os, sys
import requests
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
REF = os.path.join(HERE, '_pose_ref_family.png')
SCRATCH = os.path.join(HERE, '_refcheck')
MODEL = os.environ.get('SPLAT_MODEL', 'google/gemini-3-pro-image')
# key -> (the frame the character is read off, the words that describe it). The frame is the source of
# truth for build and colour; the words only have to stop the model averaging it into a generic zombie.
FAMS = {
'br': ('z_g1', 'an ENORMOUS FAT ZOMBIE BRUTE: a huge sagging belly over the belt, a filthy pale '
'grey-green work shirt straining over it, heavy olive-brown work trousers, thick '
'tree-trunk legs, scuffed brown boots, and a small bald head sunk low into massive '
'rounded shoulders. Far bulkier, heavier and wider than the figure in image 1'),
'sh': ('z_a1', 'a lean SHAMBLING ZOMBIE in a filthy dark red tennis shirt and grey shorts, gaunt and stooped'),
'bb': ('z_e1', 'a small young ZOMBIE BALL BOY in a pale tournament polo and shorts, slight and short'),
'st': ('z_d1', 'a ZOMBIE STEWARD in a dark uniform jacket with a high collar, stiff and official'),
'ru': ('z_f1', 'a wiry FAST ZOMBIE RUNNER in a sleeveless tennis top, lean and forward-leaning'),
# THE GROUNDSKEEPER ANCHORS ON THE HAND-FINISHED FRAME, not on z_boss1 (2026-08-16). His boss art
# is the last of the coarse legacy sheets, and the old description here ("work overalls and a
# shapeless hat") described a character the art never showed — so the roll had to average a picture
# and a paragraph that disagreed. `_ref_gk_hi` is his throw frame redrawn at the grain the other
# bosses ship at (density 0.34 against z_bosst1's 0.21) and it is now the source of truth for his
# build and colours. His ball hopper is a prop AT HEAD HEIGHT — the exact thing a 31-figure sheet
# cannot hold — so it is named and removed here, the same way the 2026-08-15 six had to be.
'gk': ('_ref_gk_hi', 'a heavy ZOMBIE GROUNDSKEEPER in a DARK GREEN CLUB BLAZER worn open over a '
'grubby pale shirt, with a small gold club crest on the breast pocket, brown '
'leather braces over the shoulders, loose olive-brown work trousers and heavy '
'brown work boots. A gaunt grey-green head with sunken yellow eyes and bared '
'teeth, no hat, no hair. The wire basket of tennis balls image 2 shows on his '
'shoulder is NOT part of him: there is no basket and there are no loose balls '
'in any pose'),
'lj': ('z_judge1', 'a ZOMBIE LINE JUDGE in a dark blazer and pale trousers, upright and severe'),
# the 2026-08-13 four (Oliver picked them): each anchors on its own settled BOSS frame, so the
# match opponent and the chapter boss are the same person — which is the roster's whole narrative
'dr': ('z_dir1', 'a ZOMBIE TOURNAMENT DIRECTOR in a navy club blazer with a gold crest, a striped '
'tie, glasses, a laminated ID badge on a lanyard, and tan trousers — stiff and officious'),
'ch': ('z_champ1', 'an ENORMOUS HULKING ZOMBIE CHAMPION, a mountain of muscle: shoulders TWICE as '
'wide as the figures in image 1, arms thicker than their legs, a massive barrel '
'chest straining a stained vintage white tennis kit with a gold-trimmed collar, '
'a bandage wrapped around his head, sweatbands. Image 2 shows him STANDING NEXT '
'TO one of image 1\'s men at true scale: THAT is the size difference every '
'figure keeps, in every pose. If he is as slim as his neighbour, it is WRONG',
'z_r1'),
'ph': ('z_physio1', 'a ZOMBIE PHYSIO in a white polo shirt with a red cross on the chest, glasses, '
'bright blue medical gloves, and dark green trousers'),
'ma': ('z_mascot1', 'a ZOMBIE MASCOT wearing a GIANT FUZZY YELLOW TENNIS BALL costume: his gaunt '
'grey-green head pokes out of a hole in the ball, thin ragged green arms and '
'legs stick out of it, one white glove, worn sneakers. The huge yellow ball IS '
'his torso in every pose'),
# the 2026-08-15 six (Oliver: expand the ladder). Four anchor on their own settled BOSS frame and
# two on their gore-mode body, same rule as above — the rung and the thing that hunts you are one
# person. EVERY ONE OF THEM CARRIES A PROP IN ITS ANCHOR FRAME (a ball basket, a coil of string, a
# tray, an umpire's chair, a spectator's chair, a court roller) and a prop is exactly what a
# 31-figure sheet cannot hold: it merges frames in the cut and it has no business in a tennis pose.
# So each description names the prop and says it is GONE — the model needs telling, because the
# reference picture is holding one and a picture beats a paragraph.
'hc': ('z_coach1', 'a ZOMBIE HEAD COACH in a NAVY BLUE TRACKSUIT: a zip-up navy jacket with a pale '
'blue-and-white panel across the chest and white stripes down the sleeves, a '
'club crest, a whistle on a cord round his neck, matching navy tracksuit '
'trousers with white side stripes, dark trainers, and a bald grey-green head. '
'He carries NO basket and NO balls: both hands are empty of props in every pose'),
'sg': ('z_string1', 'a ZOMBIE RACKET STRINGER: a grubby off-white shirt with the sleeves rolled to '
'the elbow under a heavy TAN LEATHER APRON, a row of awls and pliers across '
'the apron chest, a tool belt, faded blue-grey jeans and scuffed brown boots. '
'He carries NO loose coils of string and nothing floats above his head — the '
'apron and the tools on it are the whole costume'),
'sb': ('z_berry1', 'a ZOMBIE STRAWBERRY SELLER: a folded WHITE PAPER SERVING CAP, a cream shirt, a '
'WHITE APRON blotched and dripping with crushed red strawberry stain, dark '
'brown trousers and heavy boots. The tray of strawberries and the ladle image 2 '
'shows are NOT part of him: leave the tray and the ladle out of the picture '
'entirely, and never draw them in place of a racket'),
'cu': ('z_chair1', 'a ZOMBIE CHAIR UMPIRE: a pale CREAM LINEN BLAZER over a white shirt with a '
'dark navy tie, a pale straw sun hat with a band, olive-brown trousers and dark '
'shoes. The umpire\'s high chair, the ladder and the microphone image 2 shows '
'are NOT part of him: leave all three out of the picture entirely'),
'sp': ('z_s1', 'a gaunt scrawny ZOMBIE SPECTATOR in a drab pale OLIVE-KHAKI summer jacket over a '
'grubby shirt, dark olive trousers and worn shoes, thin and round-shouldered. The '
'wooden folding chair image 2 shows over his shoulder is NOT part of him: leave the '
'chair out of the picture entirely, and never draw it in place of a racket'),
'rm': ('z_k1', 'a big heavy ZOMBIE ROLLER MAN: a sleeveless OLIVE-GREEN WORK VEST over bare '
'grey-green arms, a brown leather tool belt with a pouch on it, olive work trousers '
'and heavy dark boots, bald. The court roller image 2 shows him hauling is NOT part '
'of him: leave the roller and its handle out of the picture entirely, and never '
'draw them in place of a racket'),
}
def key():
if os.environ.get('OPENROUTER_API_KEY'): return os.environ['OPENROUTER_API_KEY']
for line in open(os.path.join(HERE, '..', '.env')):
if line.startswith('OPENROUTER_API_KEY='):
return line.split('=', 1)[1].strip().strip('"').strip("'")
sys.exit('no OPENROUTER_API_KEY')
def char_ref(k, spr, pair=None):
"""IMAGE 2: the character, at 4x NEAREST on magenta. Raw sprite pixels, no round trip — the same
rule the pose sheet now follows, and for the same reason.
`pair` puts a SECOND sprite beside it at true relative scale. A picture beats a paragraph (the
project's own lesson, both ways): three rolls of prompt language could not make the champion
keep his bulk against the pose sheet's slim figures — showing him STANDING NEXT TO the club pro
is the claim the words kept failing to make."""
im = Image.open(os.path.join(HERE, spr + '.png')).convert('RGBA')
big = im.resize((im.width * 4, im.height * 4), Image.NEAREST)
pad = round(big.height * 0.08)
tiles = [big]
if pair:
pim = Image.open(os.path.join(HERE, pair + '.png')).convert('RGBA')
tiles.append(pim.resize((pim.width * 4, pim.height * 4), Image.NEAREST))
w = sum(t.width for t in tiles) + pad * (len(tiles) + 1)
h = max(t.height for t in tiles) + 2 * pad
out = Image.new('RGB', (w, h), (255, 0, 255))
x = pad
for t in tiles:
out.paste(t, (x, h - pad - t.height), t) # shared ground line: the size difference IS the message
x += t.width + pad
p = os.path.join(SCRATCH, f'char_{k}.png')
os.makedirs(SCRATCH, exist_ok=True)
out.save(p)
return p
def prompt(who):
return (
'Take IMAGE 1 and redraw it with ONE change: the character.\n\n'
'IMAGE 1 is a sprite sheet of a zombie tennis player: 31 figures in 9 rows on a flat magenta '
'background, first undamaged, then with one arm torn off, then with the head gone.\n\n'
f'IMAGE 2 is THE CHARACTER to draw instead: {who}. It is a walking corpse with dead '
'grey-green skin. Copy its build, its garments and every one of its colours exactly.\n\n'
'Keep IMAGE 1 EXACTLY as it is in every other respect: the same 9 rows, the same number of '
'figures per row, the same order, the same poses, the same facing, the same sizes, the same '
'damage states in the same places. A figure with no head keeps no head; a figure missing an arm '
'keeps that arm missing with the same stump and blood. Only the body wearing the poses changes. '
'Do not carry over the previous character\'s shirt, shorts, shoes, hair or headband.\n\n'
# "the same sizes" above is about LAYOUT, and the model reads it as licence to keep image 1's
# slim build — the champion came back club-pro-thin twice. The build belongs to the CHARACTER.
'THE CHARACTER\'S OWN BUILD OVERRIDES IMAGE 1\'S: if image 2 shows a wider, heavier or fatter '
'body than the figures of image 1, every redrawn figure keeps that wider, heavier body in '
'every pose. Only the POSE comes from image 1 — never the physique.\n\n'
# THE RACKET IS THE POSE, NOT A PROP, and it has to be said out loud (2026-08-15). Four of the
# six rolls that day were of characters who carry something in their anchor frame — a tray, a
# court roller, a folding chair — so their descriptions had to say the thing is gone, and the
# model read "he carries nothing" as covering the racket too: two families came back playing
# tennis bare-handed and one returned every shot with a strawberry tray.
'EVERY FIGURE STILL HOLDS THE TENNIS RACKET image 1 gives it, in the same hand, in the same '
'frames. The racket belongs to the POSE and is never removed, never replaced by anything the '
'character is carrying in image 2, and no figure that has one in image 1 has an empty hand.\n\n'
# EVERY UNDAMAGED FIGURE HAS A HEAD, said out loud. The clause above ("a figure with no head
# keeps no head") is there for the wound rows and the old normalisation pass took it as licence
# to remove one from the play row — which is how six families came back with a decapitated
# shuffle frame. The reference no longer shows that; the prompt no longer implies it either.
'EVERY FIGURE IN THE FIRST THREE ROWS HAS A HEAD ON ITS SHOULDERS, and so does every figure in '
'rows 4, 5 and 6. Only the last three rows are headless. Never remove a head that image 1 shows.\n\n'
'MATCH THE DRAWING STYLE OF IMAGE 1 EXACTLY. Image 1 is hard-edged pixel art and your output '
'must be indistinguishable from it in technique: the same square pixel grid at the same pixel '
'size, the same hard black outline around every figure, the same flat blocks of colour, the '
'same stepped aliased edges, the same hand-placed dithering. No soft brushwork, no airbrushing, '
'no blur, no gradients, no anti-aliasing.\n\n'
'The background is a solid, flat, uniform chroma key. Use EXACTLY hex color #FF00FF, one single '
'flat tone across the whole canvas behind every figure, with no panels or boxes of any other '
'colour. The inside of every racket head is that same EXACT #FF00FF. No text, no shadows, '
'no ground lines.')
def u(p):
return 'data:image/png;base64,' + base64.b64encode(open(p, 'rb').read()).decode()
def roll(k):
spr, who = FAMS[k][:2]
pair = FAMS[k][2] if len(FAMS[k]) > 2 else None
ch = char_ref(k, spr, pair)
r = requests.post('https://openrouter.ai/api/v1/images',
headers={'Authorization': f'Bearer {key()}', 'Content-Type': 'application/json'},
# 4K and 9:16: the endpoint DEFAULTS to 1K and snaps the aspect to an enum, so both are
# spelled out. The reference is composed at 9:16 for exactly this reason.
json={'model': MODEL, 'prompt': prompt(who), 'resolution': '4K', 'aspect_ratio': '9:16',
'input_references': [{'type': 'image_url', 'image_url': {'url': u(REF)}},
{'type': 'image_url', 'image_url': {'url': u(ch)}}]},
timeout=1800)
if r.status_code != 200:
print(f'{k}: HTTP {r.status_code} {r.text[:200]}'); return
d = r.json().get('data') or []
if not d:
print(f'{k}: no image returned'); return
b = d[0].get('b64_json') or d[0].get('image_url', {}).get('url', '').split(',')[-1]
out = os.path.join(SCRATCH, f'fam_{k}.png')
open(out, 'wb').write(base64.b64decode(b))
print(f'{k}: {out} {Image.open(out).size} (character from {spr})', flush=True)
if __name__ == '__main__':
keys = [a for a in sys.argv[1:] if not a.startswith('-')]
if not keys: sys.exit(f'usage: _famroll.py <key>... keys: {" ".join(FAMS)}')
for k in keys:
if k not in FAMS: sys.exit(f'unknown family {k}')
roll(k)