Files
splatter-tennis/assets/_regen.py
T
wuwiandClaude Opus 5 01498eddad Turn the bodies around, calm the racket zombie, break up the pack
Three from the playtest, all code:

Every side-on sprite is DRAWN facing left, and nothing ever mirrored them, so a
zombie walking right moonwalked — obvious the moment they close on you across
your own half. They face where they last walked now. The front-facing ones (the
turned CPU, the machine, a chapter boss) have no left or right and are left alone.

The racket zombie holds a position and only moves when the ball makes him, so a
time-driven walk cycle flicked him between two frames on the spot. His stride
steps off ground covered, like the brute's — standing still is standing still.

And the pack no longer stands on the body twitching for as long as the summary is
up: once it appears they break up and shamble away up-court. Measured — feeding
at 3s, dispersed to 3m by 6.5s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:24:11 +02:00

138 lines
7.7 KiB
Python

# _regen.py — redraw a batch of sprites at real pixel-art resolution, end to end.
#
# python3 assets/_regen.py shambler_a # one batch
# python3 assets/_regen.py --list # what batches exist
# python3 assets/_regen.py --all # everything not already done
#
# Per batch it: builds a reference sheet from the sprites in the repo, asks the generator to redraw
# them at HIGH art resolution (the style block below is the whole point — the previous prompts asked
# for "big chunky pixel blocks", which is a specification for coarse art), slices with _fine.py,
# colour-matches each frame onto what it replaces, and installs — but only if the sheet passes its
# checks: right number of frames, nothing touching a canvas edge (that is what clipped z_f1), and a
# real gain in inner-edge density (the number that tracks "is the drawing actually finer").
#
# Failures leave the sprites alone and print why, so a batch can just be re-run.
import json, os, subprocess, sys
import numpy as np
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
SCRATCH = os.environ.get('SPLAT_SCRATCH', '/tmp/splatter-regen')
GEN = os.path.expanduser('~/.claude/skills/generate-image/scripts/generate_image.py')
PY = os.environ.get('SPLAT_PY', sys.executable)
STYLE = (
"Redraw them as HIGH-RESOLUTION pixel art. The art pixels must be SMALL: build each figure out "
"of roughly 90 to 110 pixels of height instead of the reference's 40, so there is room for real "
"detail, and spend it — a face with teeth, sunken eyes and a jawline; individual fingers; "
"clothing with visible seams, hems, folds and frayed torn edges; laced shoes; four or five "
"distinct shades per material with fine hatched shading where the light falls away. Keep every "
"pose, every body proportion, every height, every facing and every wound EXACTLY as in the "
"reference — only the drawing resolution and the amount of fine detail change. Keep the same "
"colours. Hard black outline around each figure. The background must be ONE FLAT solid magenta "
"#FF00FF across the entire canvas — no scenery, no gradient, no vignette. Keep at least 200 "
"pixels of empty magenta between figures and at least 150 pixels of empty magenta at every edge "
"of the canvas: no figure or limb may touch or run off an edge. No shadow, no ground line, no text."
)
def batches():
with open(os.path.join(HERE, '_regen_batches.json')) as f:
return json.load(f)
# ---------------------------------------------------------------- helpers ----
def ref_sheet(names, out, scale=2.0, gap=220, pad=150):
ims = [Image.open(os.path.join(HERE, n + '.png')).convert('RGBA') for n in names]
ims = [i.resize((max(1, int(i.width * scale)), max(1, int(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), (255, 0, 255, 255))
x = pad
for i in ims:
s.paste(i, (x, h - pad - i.height), i); x += i.width + gap
s.save(out)
return out
def density(path):
"""Inner-edge density: how much detail lives INSIDE the silhouette. Coarse art ~0.15, the
redrawn runner ~0.45. This is the number that matched Oliver's eye."""
a = np.array(Image.open(path).convert('RGBA'))
solid = a[..., 3] > 128
d = np.abs(np.diff(a[..., :3].astype(int), axis=1)).sum(2)
return round(float(((d > 40) & solid[:, :-1]).sum() / max(1, solid.sum())), 3)
def touches_edge(sheet):
a = np.array(Image.open(sheet).convert('RGB')).astype(int)
bg = a[0, 0]
fg = (((a - bg) ** 2).sum(2) > 3600) & ~(np.minimum(a[..., 0], a[..., 2]) > a[..., 1] + 40)
return bool(fg[0].any() or fg[-1].any() or fg[:, 0].any() or fg[:, -1].any())
def colour_match(src, dst_name):
"""Fit the new frame onto the palette statistics of the sprite it replaces, so a redrawn body
does not change colour halfway through a walk cycle."""
op = lambda x: x[..., 3] > 200
old = np.array(Image.open(os.path.join(HERE, dst_name + '.png')).convert('RGBA'), dtype=float)
new = np.array(Image.open(src).convert('RGBA'), dtype=float)
po, pn = old[op(old)][:, :3], new[op(new)][:, :3]
m = op(new)
px = (new[m][:, :3] - pn.mean(0)) / np.maximum(pn.std(0), 1e-6) * po.std(0) + po.mean(0)
new[m] = np.concatenate([np.clip(px, 0, 255), new[m][:, 3:]], axis=1)
Image.fromarray(new.astype('uint8'), 'RGBA').save(src)
# ------------------------------------------------------------------- run ----
def run(name, spec, dry=False):
os.makedirs(SCRATCH, exist_ok=True)
sprites = spec['sprites']
lead = spec.get('ref') # generated for scale, thrown away after
ref = ref_sheet(([lead] if lead else []) + sprites, f'{SCRATCH}/{name}_ref.png', spec.get('scale', 2.0))
sheet = f'{SCRATCH}/{name}_hi.png'
prompt = f"{spec['subject']} {STYLE}"
if dry:
print(f'[{name}] would generate {len(sprites)} frames: {", ".join(sprites)}'); return True
print(f'[{name}] generating {len(sprites)} frames...')
r = subprocess.run([PY, GEN, prompt, '--input', ref, '--output', sheet],
capture_output=True, text=True, cwd=os.path.dirname(HERE))
if not os.path.exists(sheet):
print(f'[{name}] FAILED: generator returned no image\n{r.stdout[-400:]}'); return False
if touches_edge(sheet):
print(f'[{name}] REJECTED: a figure runs off the canvas edge — re-run'); return False
outs = [f'{SCRATCH}/{name}_{i}.png' for i in range(len(sprites) + (1 if lead else 0))]
cut = ['--cut', spec['cut']] if spec.get('cut') else []
r = subprocess.run([PY, os.path.join(HERE, '_fine.py'), sheet, *outs,
'--anchor', str(spec.get('anchor', 0)), '--h', str(spec.get('h', 224)), *cut],
capture_output=True, text=True)
print(r.stdout.strip() or r.stderr.strip())
if r.returncode or not all(os.path.exists(o) for o in outs):
print(f'[{name}] REJECTED: slicing failed (frames merged?) — re-run or add "cut"'); return False
if lead: outs = outs[1:] # the scale reference is not installed
before = [density(os.path.join(HERE, s + '.png')) for s in sprites]
after = [density(o) for o in outs]
# 1.25x, not 1.5x: a sheet that already has blood on it starts with a high edge density, so a
# genuinely finer redraw of a wounded body scores a smaller gain than a clean one.
if sum(after) / len(after) < sum(before) / len(before) * 1.25:
print(f'[{name}] REJECTED: not finer than what it replaces {before} -> {after}'); return False
for o, s in zip(outs, sprites):
colour_match(o, s)
Image.open(o).save(os.path.join(HERE, s + '.png'))
print(f'[{name}] installed. detail {before} -> {after}')
return True
if __name__ == '__main__':
args = sys.argv[1:]
B = batches()
if not args or '--list' in args:
for k, v in B.items(): print(f'{k:16s} {len(v["sprites"]):2d} frames {", ".join(v["sprites"])}')
sys.exit()
dry = '--dry' in args
tries = 3 # a rejected sheet is usually just a bad roll:
for a in args: # frames merged, or a limb off the canvas
if a.startswith('--tries='): tries = int(a.split('=')[1])
names = list(B) if '--all' in args else [a for a in args if not a.startswith('--')]
bad = []
for n in names:
for attempt in range(1, tries + 1):
if run(n, B[n], dry): break
print(f'[{n}] attempt {attempt}/{tries} rejected')
else:
bad.append(n)
print('\nFAILED BATCHES (re-run):', ' '.join(bad) if bad else 'none')