Files
splatter-tennis/assets/_derive.py
T
wuwiandClaude Opus 5 b1f8ea3240 Wind the serve up with the pull, and stop the frames flickering
Four new frames (p_pull1..4) turn the slingshot drag into the serve's wind-up:
racket above the head, behind the head, behind the back, fully coiled, picked off
pullFrac — the same 0..1 that sets the shot's power — so the gesture and the man
are one movement and the release snaps to the smash.

The two bounce frames are one drawing now. p_bounce2 is p_bounce with the free
arm sheared up 12px and nothing else touched, so the swap cannot flicker, and it
follows the ball instead of its rising phase; BOUNCE_IDLE was re-measured off the
sprite so the ball tops out under the palm. p_toss had the tossed ball drawn into
it — cut, canvas height kept, because the game scales him off img.height.

The runner: hip centres sat -2.0/+7.1/-6.0/-0.1 px off the sprite centre, a 13px
lateral jump at 12 frames a second, and Oliver's diagnosis was right that all four
frames leaned the same way and lifted the same leg. Frames 3 and 4 mirrored, all
four padded to one width with the hip on the centre line.

assets/_derive.py holds the three derivations, since "the same drawing with one
small difference" is the one thing the generator cannot draw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:51:30 +02:00

72 lines
3.7 KiB
Python

# _derive.py — the frame fixes that must NOT go through the generator.
#
# python3 assets/_derive.py
#
# Three sprites are derived from other sprites by arithmetic, because the thing that matters about
# them is that they stay pixel-identical to their neighbour. Ask the generator for "the same drawing
# with one small difference" and it returns a different man, and the swap reads as a flicker — which
# is exactly the bug each of these fixes. Re-run this after regenerating p_bounce or the runner;
# every step is idempotent, so running it twice changes nothing.
import numpy as np
from PIL import Image
import os
A = os.path.dirname(os.path.abspath(__file__)) + '/'
load = lambda n: np.array(Image.open(A + n + '.png').convert('RGBA'))
save = lambda a, n: Image.fromarray(a, 'RGBA').save(A + n + '.png')
# 1. p_toss — the generator drew the tossed ball into the sprite; the game draws the real ball.
# The CANVAS keeps its height: drawChar scales the man off img.height, so re-cropping the dead
# space would shrink him.
a = load('p_toss')
rows = (a[..., 3] > 128).any(1)
cut = int(np.argmax(~rows)) # the ball floats clear above the raised hand
if cut and not rows[cut:cut + 8].any():
a[:cut] = 0; save(a, 'p_toss')
print(f'p_toss: ball removed (rows 0..{cut})')
else:
print('p_toss: already ball-free')
# 2. p_bounce2 — p_bounce with the free arm bent so the hand rises 12 px (0.10 m) to meet the ball
# at the top of its bounce. A per-column shear, pinned at the shoulder and rigid from the wrist out,
# so the forearm bends instead of the hand detaching.
X0, K, MAX = 96, 0.8, 12
b = load('p_bounce'); out = b.copy()
for x in range(X0, b.shape[1]):
s = min(MAX, round(K * (x - X0)))
if s: out[:-s, x] = b[s:, x]; out[-s:, x] = 0
save(out, 'p_bounce2')
print('p_bounce2: derived from p_bounce')
# 3. z_f1..4 — the runner, with `--runner`. This one is NOT idempotent (it mirrors, and it pads),
# so it is opt-in: run it exactly once on a freshly generated set of runner frames.
# Two defects, both measured. The generator drew all four frames leaning the same way and lifting
# the SAME leg, so the cycle never used his other side — mirroring frames 3 and 4 gives an
# alternating stride out of the poses that exist (he is front-on, so a mirror is free). And each
# frame is cropped to its own bbox and drawn centred on the body's x, so a swinging arm slid the
# whole man sideways: hip centres sat -2.0/+7.1/-6.0/-0.1 px off centre, a 13 px lateral jump at 12
# frames a second — that was the hard flicker. Pad every frame to one width with the HIP on the
# centre line. Padding is free: drawStatic derives the drawn width from img.width, so the figure
# inside keeps its scale and only its anchor moves.
import sys
if '--runner' not in sys.argv:
print('z_f1..4: skipped (pass --runner; it mirrors and pads, so it must run only once)')
raise SystemExit
NS = ['z_f1', 'z_f2', 'z_f3', 'z_f4']
for n in NS[2:]:
save(np.array(Image.open(A + n + '.png').convert('RGBA').transpose(Image.FLIP_LEFT_RIGHT)), n)
print(f'{n}: mirrored — the other leg leads')
ims = [load(n) for n in NS]
def hip(a): # midpoint of the hip band, not the mean: an arm
al = a[..., 3] > 128 # crossing the band would drag a mean sideways
xs = np.nonzero(al[int(al.shape[0] * .50):int(al.shape[0] * .62)].any(0))[0]
return (xs.min() + xs.max()) / 2
hips = [hip(a) for a in ims]
W = int(np.ceil(max(max(h, a.shape[1] - h) for h, a in zip(hips, ims)) * 2))
for n, a, h in zip(NS, ims, hips):
left = int(round(W / 2 - h))
o = np.zeros((a.shape[0], W, 4), np.uint8)
o[:, left:left + a.shape[1]] = a
save(o, n)
print(f'z_f1..4: hip-registered onto {W}px')