Files
splatter-tennis/assets/_master.py
T
wuwiandClaude Opus 5 4bec0cb09c --variant now emits the whole ladder: nine batches, thirty-one frames
A variant used to stop at the healthy eleven, which is enough for a gore-mode
shambler and not enough for a MATCH opponent — that mode scores you on the
BODY, so an opponent with no wounded art would go from untouched to dead with
nothing in between.

  <key>_play / _serve / _fall       restyled off the master        11 frames
  <key>_w1  / _serve_w1 / _fall_w1  arm off, drawn from healthy    10 frames
  <key>_w2  / _serve_w2 / _fall_w2  headless, drawn from arm off   10 frames

Each stage is a restyle of THE STAGE ABOVE IT, in that order, because that
chaining is what finally made damage accumulate this morning: the missing arm
rides in the picture instead of having to win an argument with it. The helper
prints the batches in dependency order for exactly that reason.

Every gate the racket zombie's ladder needed is declared automatically — the
stride pair on both the healthy shuffle and each wound row, `changed` at 0.10
for an arm and 0.15 for a head (the generator will happily redraw the reference
and skip the amputation), and the pose-tolerant kit bounds with the shorts hue
kept tight, since hue is the measure that survives a pose change.

The wound rows carry three frames, not four: the shuffle doubles as the stance,
which is how drawZombie already reads them.

And the one-armed serve is generated, not hand-waved — the missing arm IS the
tossing arm, so the stump goes up and the racket hand flicks the ball off the
face, the way one-armed players actually serve.

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

165 lines
10 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).
#
# 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
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. ")
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):
for src, dst in zip(refs, sprites): # placeholders: the density bar needs a baseline to beat
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
# pose-varied sheets move redness and shorts BRIGHTNESS on their own (a body face down
# shows a fraction of the shirt it shows standing); the shorts HUE is the measure that
# survives a pose change, so it stays the tight one. See the note in _regen.kit.
spec['palette_ref'] = refs[0]
spec['palette_tol'] = [26, 14, 99, 5]
B[name] = spec
made.append(name)
print(f'{name:16s} <- {refs[0]}… {", ".join(sprites)}')
K = 'z_' + key
# --- the whole body, restyled off the master
for part, frames in SHEETS.items():
batch(f'{key}_{part}', [K + f[3:] for f in frames], frames,
RESTYLE.format(n=len(frames), who=who) + (STRIDE_NOTE if part == 'play' else ''),
stride=[[1, 2]] if part == 'play' else None, newart=True)
# --- 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)