Files
splatter-tennis/assets/_master.py
T
wuwiandClaude Opus 5 a58e8b18aa Colour is arithmetic: match each frame onto the anchor instead of asking for it
The character-as-image fix landed and did NOT close the gap, which is the answer
I promised to report rather than paper over. The brute's serve sheet came back
with his build, his garments and his DARK TONES PIXEL-IDENTICAL to the anchor —
(56,56,32) both — and his big pale shirt two shades brighter, (200,200,184)
against (176,176,152). The model was looking straight at the character. It held
identity perfectly and re-lit the largest flat area anyway, because every call
re-renders and re-rendering re-lights.

No prompt fixes that. Six gates spent a day trying to make a generator do
arithmetic.

So the arithmetic happens here now. Pair each of a frame's dominant colours with
its nearest anchor colour, fit one gain+offset per channel across those pairs,
apply it to every pixel. Pairs further apart than NEW_COLOUR are dropped from the
FIT — colours the anchor genuinely has not got, i.e. blood on a fresh wound — but
they still RECEIVE the transform, so a wound stays the right red relative to the
body it is on. The gain is clamped so one odd pair cannot posterise a frame.

PROVEN ON THE REJECTED FRAME BEFORE SPENDING A SINGLE CALL, which is the
discipline I skipped all day: the frame that measured 20.0 away passes after
correction, its shirt pulled from (200,200,184) to (176,176,160).

And the order is now CORRECT, THEN CHECK — the palette gate is a safety net for
what arithmetic could not fix, not a tripwire for what it can.

The model this leaves:
  the generator, given references  ->  identity, pose, composition
  the pipeline, deterministically  ->  exact colour
  the gates                        ->  layout accidents only

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:46:56 +02:00

212 lines
14 KiB
Python

# _master.py — build THE MASTER SHEETS: the canonical pose set a new zombie is restyled FROM.
#
# python3 assets/_master.py # rebuild assets/_sheet_master_*.png from the installed frames
#
# WHY THIS EXISTS. A new zombie variant used to mean inventing a character AND a pose set, and the
# generator answers that by inventing a fresh figure per frame — which is how the walk cycles ended
# up as eleven unrelated illustrations. Handing it a FINISHED SHEET and asking for a restyle turns
# the job into a transform: the reference does the specifying and the prompt only says who to draw.
# Measured, the poses survive it intact (the brute restyle came back at upper 0.148 / legs 0.618
# against the master's own 0.157 / 0.565 — very slightly BETTER than what it copied).
#
# WHAT THE FIRST VARIANT SHOWED, and what the next attempt should change. The brute's PLAY sheet is
# a good brute. Its serve and fall sheets are each a slightly DIFFERENT brute, because all three
# restyle from the master independently — so each one invents the character afresh from the words,
# and "pale grey-green work shirt" lands somewhere new every time. The palette gate now measures the
# gap (15 to 48 against z_br1, where a good frame is under 8.5) and refuses them.
#
# The fix is to give the later sheets BOTH things: the master frame for the POSE and the variant's
# own first sheet for the CHARACTER, instead of the words for the character. That is the same lesson
# as the wound stages — a reference beats a description — applied one level up. Until then a variant
# is reliable for its play sheet and a gamble for the other two.
#
# WHY THREE SHEETS AND NOT ONE. The generator answers at a fixed width, around 1400-1800 px. At four
# figures across each gets 350-440 px, which carries the 90-110 px of figure height the style block
# asks for. All eleven on one strip would be ~130 px each and the detail gate would reject every
# roll. Four, four and three is the shape that fits — and it is already the shape of the three
# batches that made them.
import os, sys
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
MAGENTA = (255, 0, 255, 255)
# The master pose set, split the way it is generated. Keep these in step with config.BENCH — that is
# the screen these frames are judged on.
SHEETS = {
'play': ['z_r1', 'z_r2', 'z_r3', 'z_r4'], # stance, shuffle A, shuffle B, forehand
'serve': ['z_rbounce', 'z_rtoss', 'z_rserve'], # bounce, toss, strike
'fall': ['z_rfall1', 'z_rfall2', 'z_rfall3', 'z_rrise'], # buckle, knees, down, rise
}
def build(name, frames, scale=2, gap=90, pad=70):
ims = [Image.open(os.path.join(HERE, f + '.png')).convert('RGBA') for f in frames]
ims = [i.resize((i.width * scale, i.height * scale), Image.NEAREST) for i in ims]
w = sum(i.width for i in ims) + gap * (len(ims) - 1) + pad * 2
h = max(i.height for i in ims) + pad * 2
s = Image.new('RGBA', (w, h), MAGENTA)
x = pad
for i in ims:
s.paste(i, (x, h - pad - i.height), i) # feet on one ground line, as the game draws them
x += i.width + gap
out = os.path.join(HERE, f'_sheet_master_{name}.png')
s.save(out)
print(f'{os.path.basename(out):26s} {w}x{h} {len(frames)} frames ({", ".join(frames)})')
# ---- A NEW ZOMBIE OFF THE MASTER ------------------------------------------------------------
# python3 assets/_master.py --variant br "an ENORMOUS FAT ZOMBIE BRUTE: a huge sagging belly, a
# filthy stretched shirt, heavy brown work trousers…"
# python3 assets/_regen.py br_play br_serve br_fall --tries=4
#
# Writes three batches that RESTYLE the master sheets into a new character, seeds their placeholder
# PNGs, and declares the stride pair so the gate covers the new body from its first roll. The whole
# variant is then three generator calls and no pose design at all — which is the point of having a
# master: the reference specifies eleven poses that already work, and the prompt only says who.
import json, shutil, collections
# THE FIRST SHEET describes the character in WORDS, because at that point no picture of it exists.
RESTYLE = (
"The reference image is a {n}-FRAME SPRITE SHEET of one zombie tennis player. Redraw THE WHOLE "
"SHEET as a COMPLETELY DIFFERENT ZOMBIE: {who} "
"KEEP ALL {n} POSES EXACTLY AS THEY ARE IN THE REFERENCE — the same stance, the same steps with "
"the same leg forward in each, the same swing, the same facing, the same arm and racket "
"positions, the same order left to right, the same total height and the same distance from the "
"camera. Only the CHARACTER changes; the POSES do not. "
"There is NO TENNIS BALL anywhere in any frame — the game draws the ball itself, so the hands and "
"the air are empty and the poses are the same as if a ball were there. "
"Output exactly {n} figures side by side in ONE horizontal row, all on the same ground line. ")
# ...AND EVERY SHEET AFTER IT DESCRIBES THE CHARACTER WITH A PICTURE OF IT. This is the whole fix.
# The brute's three sheets each restyled from the master independently and each re-derived "pale
# grey-green work shirt" from the same paragraph, so the game got three different brutes and the
# palette gate — correctly — refused two of them. A paragraph is a lossy specification of a
# character; the character is not.
#
# It is also the pattern that already worked: the racket zombie's own play sheet was generated from
# [z_c1, o_idle1, o_run1, o_swing1] — the character as an IMAGE, the poses as IMAGES — in one call,
# first time. I had it, and then did not use it for variants.
RESTYLE_KNOWN = (
"The FIRST reference image is THE CHARACTER — the exact zombie to draw, and the ONLY source for "
"who it is: its build, its bulk, its height, its clothing and every colour of it. Copy that "
"character exactly. "
"The REMAINING reference images are {n} POSES, and are there ONLY to show what the body is doing "
"— never who it is. "
"Draw THE CHARACTER FROM THE FIRST IMAGE in each of those {n} poses, in the same order, at the "
"same size and the same distance from the camera, all facing the same way. The character never "
"changes; only the pose does. "
"There is NO TENNIS BALL anywhere in any frame — the game draws the ball itself, so the hands and "
"the air are empty and the poses are the same as if a ball were there. "
"Output exactly {n} figures side by side in ONE horizontal row, all on the same ground line. ")
STRIDE_NOTE = ("In particular frames 2 and 3 must stay what they are in the reference: ONE DRAWING "
"SHOWN TWICE with only the legs different — everything above the hips identical "
"between them, the legs clearly swapped, and the two frames NOT identical copies. ")
# ---- THE WOUND LADDER. A match opponent is scored ON ITS BODY: every point won takes a stage off
# it, so a variant that only exists whole would jump from untouched to dead with nothing between.
# Each stage is a restyle of THE STAGE ABOVE IT — that chaining is what finally made damage
# accumulate, because the missing arm then rides in the PICTURE instead of having to win an argument
# with it. The wound rows have no separate stance frame: the shuffle doubles as one, which is how
# drawZombie already reads them.
WOUND = (
"The reference image is a {n}-FRAME SPRITE SHEET of one zombie tennis player. REDRAW THE WHOLE "
"SHEET with ONE THING CHANGED: {change} "
"EVERYTHING ELSE STAYS EXACTLY AS IT IS IN THE REFERENCE — the same character, the same clothing "
"and its exact shades, the same skin, the same racket, the same {n} poses in the same order, the "
"same leg positions, the same facing, the same build. There is NO TENNIS BALL in any frame. "
"Output exactly {n} figures side by side in ONE horizontal row, all on the same ground line. ")
ARM_OFF = (
"the arm WITHOUT the racket is COMPLETELY GONE — torn off at the shoulder, leaving a ragged "
"stump gushing blood down that side and the clothing slashed open across the ribs. COUNT THE "
"ARMS: there must be EXACTLY ONE ARM in every single frame, the one holding the racket. Nothing "
"hangs from the other shoulder. A frame with two arms in it is wrong — this is the whole change "
"being asked for, so do not copy the reference's second arm across. Where the reference uses "
"that arm, the stump is thrown out in its place instead. ")
HEADLESS = (
"the HEAD IS COMPLETELY GONE — no head, no face, no hair anywhere above the shoulders, only a "
"ragged gushing neck stump, so every figure is A CLEAR HEAD SHORTER than in the reference. It is "
"ALREADY missing one arm in the reference and it STAYS missing — it still has only one arm. ")
# ...and because the missing arm IS the tossing arm, a wounded server has to toss the way one-armed
# players really do. The wound changing how it plays is the point, not a detail.
ONE_ARM_SERVE = (
"Because it has only one arm it can no longer throw the ball up with a free hand: in the TOSS "
"frame the stump is flung upward instead and the racket hand flicks up in front of the face, as "
"a one-armed player tosses off the racket face. ")
def variant(key, who):
bpath = os.path.join(HERE, '_regen_batches.json')
B = json.load(open(bpath, encoding='utf-8'), object_pairs_hook=collections.OrderedDict)
seed = lambda src, dst: shutil.copy(os.path.join(HERE, src + '.png'), os.path.join(HERE, dst + '.png'))
made = []
def batch(name, sprites, refs, subject, stride=None, changed=None, newart=False, lead=False):
# `lead` means the first reference is THE CHARACTER and not a pose — it is shown to the
# generator and is not one of the frames asked for, so it is skipped when pairing references
# to outputs (both for seeding and for the run-time baseline in _regen).
pose_refs = refs[1:] if lead else refs
# Only the sheets drawn off the MASTER are seeded here. A chained wound stage is seeded by
# _regen at run time, from whatever its reference has become by then — seeding it now would
# score it against a copy of the master, two steps better than the stage it follows.
if newart:
for src, dst in zip(pose_refs, sprites):
seed(src, dst)
spec = dict(sprites=sprites, ref_only=list(refs), check='loose', anchor=0, subject=subject)
spec['newart' if newart else 'state'] = True
if stride: spec['stride'] = stride
if changed: spec['changed'] = changed
# EVERY SHEET OF A VARIANT IS PAINTED IN THE FIRST SHEET'S COLOURS. Anchoring each stage to
# the one before it let a legal hop compound into a drift nobody authorised; anchoring all of
# them to the healthy body means the whole variant, wounds and all, stays one character.
# The first sheet is what DEFINES the palette, so it is the only one not measured against it.
if sprites[0] != K + '1':
spec['palette_anchor'] = K + '1'
B[name] = spec
made.append(name)
print(f'{name:16s} <- {refs[0]}… {", ".join(sprites)}')
K = 'z_' + key
# --- the whole body. The PLAY sheet invents the character from the words; serve and fall are then
# drawn from the character it produced (K+'1' leads their reference list) plus the master's poses.
# That ordering is why the three must be run in the order printed at the end.
for part, frames in SHEETS.items():
first = part == 'play'
refs = frames if first else [K + '1'] + frames
batch(f'{key}_{part}', [K + f[3:] for f in frames], refs,
(RESTYLE.format(n=len(frames), who=who) + STRIDE_NOTE) if first
else RESTYLE_KNOWN.format(n=len(frames)),
stride=[[1, 2]] if first else None, newart=True, lead=not first)
# --- the two wound stages, each chained off the one above it
WALK = ['2', '3', '4'] # shuffle A, shuffle B, forehand — the wound rows' three frames
SERVE = ['bounce', 'toss', 'serve']
FALL = ['fall1', 'fall2', 'fall3', 'rise']
for st, change, extra, moved in ((1, ARM_OFF, ONE_ARM_SERVE, 0.10), (2, HEADLESS, '', 0.15)):
up = K + (f'w{st - 1}' if st > 1 else '') # the stage this one is drawn from
walk_src = [up + s for s in (['', 'b', 's'] if st > 1 else WALK)]
batch(f'{key}_w{st}', [f'{K}w{st}', f'{K}w{st}b', f'{K}w{st}s'], walk_src,
WOUND.format(n=3, change=change)
+ 'In particular frames 1 and 2 are ONE DRAWING SHOWN TWICE with only the legs '
'different — identical above the hips, legs clearly swapped, and NOT identical copies. ',
stride=[[0, 1]], changed=moved)
batch(f'{key}_serve_w{st}', [f'{K}w{st}' + s for s in SERVE], [up + s for s in SERVE],
WOUND.format(n=3, change=change) + extra, changed=moved)
batch(f'{key}_fall_w{st}', [f'{K}w{st}' + s for s in FALL], [up + s for s in FALL],
WOUND.format(n=4, change=change), changed=moved)
json.dump(B, open(bpath, 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
print(f'\n{len(made)} batches, 31 frames. Run them IN THIS ORDER — each wound stage is drawn '
f'from the stage above it, so an earlier one must be installed first:\n\n'
f' python3 assets/_regen.py {" ".join(made)} --tries=4\n')
if __name__ == '__main__':
if '--variant' in sys.argv:
i = sys.argv.index('--variant')
variant(sys.argv[i + 1], sys.argv[i + 2])
sys.exit()
for name, frames in SHEETS.items():
missing = [f for f in frames if not os.path.exists(os.path.join(HERE, f + '.png'))]
if missing:
print(f'[{name}] SKIPPED, missing: {", ".join(missing)}'); continue
build(name, frames)