Files

223 lines
16 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'),
# 2026-08-29 (Oliver): the roll dropped his HI-VIS VEST — the words said "dark uniform jacket"
# while the anchor frame z_d1 wears bright orange over it, and the words won. Name the vest.
'st': ('z_d1', 'a ZOMBIE STEWARD wearing a BRIGHT ORANGE HI-VIS SAFETY VEST with grey reflective '
'stripes OVER a dark uniform jacket, stiff and official. The orange vest is on him in '
'EVERY single pose — it is his uniform'),
'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)