Files
wuwiandClaude Opus 5 49d391dc64 Make it readable: 2x sprites, a flatter camera, honest hit timing
The umpire is situational again. Each line is tied to the thing that justifies
it — a new variant appearing, the court filling, one crossing your baseline, a
game lost, a horde, a changeover — behind one shared cooldown, so several
triggers at once still yield one remark. The random timer pool is gone, the
interval went 26s to 55s, and ump_out_of_order, ump_let and ump_no_appeal are
deleted.

"Visually the ball is not yet at the player but somehow the player already is
hit" was real and measurable. The hostile-ball hit test reused the parry's
hypot(dx, dz*0.8), and weighting depth shrinks it: contact fired while the ball
was still 0.68 m up-court and drawn up around his head. It has its own
unweighted test now, and contact lands at 0.25 m with the ball on his chest.
The parry keeps its weighting — a racket sweep really is wider than it is deep.

On "everything is a tad too small": measured first, because higher-resolution
sprites are only half the answer. At RES 3 the player rasterises into 170
device pixels from a 112px source — upscaled 1.5x — while a far shambler is
downscaled to 78. So near bodies needed resolution and far bodies needed the
camera.

Camera: pushed back and lengthened (camZ 42->48, camF 580->760) to flatten the
perspective so distant bodies grow relative to near ones rather than everything
zooming, camY raised for a steeper look-down, horizon re-centred. Far shambler
26->30px, boss 39->45px, player unchanged, court in the same band.

Sprites: the original sheets were still in the repo at full resolution, so
nothing needed regenerating — IDLE_TARGET_H went to 224 and every sheet was
re-sliced. The sheet-to-output mapping was never recorded, so it was recovered
by matching sliced frames to existing assets by image signature: 58 came back
from sheets, the 29 without a confident match were 2x-upscaled to stay on the
shared scale, since a sprite off that scale renders at half height.

Tennis untouched: 50 hits / 0 whiffs. 45-game soak clean, all four chapters.

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

72 lines
3.4 KiB
Python

# slice.py — chroma-key a generated sprite sheet on its corner color, split into
# frames on column gaps, common-scale the sheet so the tallest frame = IDLE_TARGET_H,
# save each frame to the given output paths. Usage: slice.py sheet.png out1.png out2.png ...
import sys
from PIL import Image
IDLE_TARGET_H = 224 # 2x remaster: the player renders at ~170 DEVICE px (RES 3), so 112 was being upscaled
KEY_D2 = 60 * 60 # squared RGB distance to the bg color that still counts as background
MIN_GAP = 12 # empty columns that separate frames
MIN_BLOB = 60 # despeckle: drop opaque islands smaller than this many pixels
def main(sheet_path, out_paths, largest_only=False):
im = Image.open(sheet_path).convert('RGBA')
w, h = im.size
px = im.load()
# background = corner color (average the four corners that agree with the top-left)
bg = px[0, 0][:3]
for x in range(w):
for y in range(h):
r, g, b, a = px[x, y]
if (r - bg[0]) ** 2 + (g - bg[1]) ** 2 + (b - bg[2]) ** 2 < KEY_D2 \
or (min(r, b) > g + 40): # de-fringe: magenta-hued halo pixels (sprites have no magenta)
px[x, y] = (0, 0, 0, 0)
# despeckle: flood-fill opaque components; wipe tiny ones (halos, dust).
# largest_only additionally wipes everything but the biggest blob (stray fragments
# from splitting a sheet whose frames touch — e.g. the brute's dragged pole).
seen = [[False] * h for _ in range(w)]
blobs = []
for x in range(w):
for y in range(h):
if seen[x][y] or px[x, y][3] == 0: continue
stack, blob = [(x, y)], []
seen[x][y] = True
while stack:
cx, cy = stack.pop()
blob.append((cx, cy))
for nx, ny in ((cx+1,cy),(cx-1,cy),(cx,cy+1),(cx,cy-1)):
if 0 <= nx < w and 0 <= ny < h and not seen[nx][ny] and px[nx, ny][3] > 0:
seen[nx][ny] = True; stack.append((nx, ny))
blobs.append(blob)
keep = max(blobs, key=len) if (largest_only and blobs) else None
for blob in blobs:
if blob is keep: continue
if largest_only or len(blob) < MIN_BLOB:
for cx, cy in blob: px[cx, cy] = (0, 0, 0, 0)
# segment on empty column runs
cols = [any(px[x, y][3] for y in range(h)) for x in range(w)]
frames, start, gap = [], None, 0
for x in range(w):
if cols[x]:
if start is None: start = x
gap = 0
elif start is not None:
gap += 1
if gap >= MIN_GAP:
frames.append((start, x - gap + 1)); start = None
if start is not None: frames.append((start, w))
crops = []
for x0, x1 in frames:
c = im.crop((x0, 0, x1, h))
crops.append(c.crop(c.getbbox()))
if len(crops) != len(out_paths):
sys.exit(f'ERROR: found {len(crops)} frames, expected {len(out_paths)} ({sheet_path})')
k = IDLE_TARGET_H / max(c.height for c in crops) # one scale per sheet keeps relative frame sizes
for c, out in zip(crops, out_paths):
c.resize((max(1, round(c.width * k)), max(1, round(c.height * k))), Image.NEAREST).save(out)
print(f'{out}: {round(c.width * k)}x{round(c.height * k)}')
if __name__ == '__main__':
args = [a for a in sys.argv[1:] if a != '--largest']
main(args[0], args[1:], largest_only='--largest' in sys.argv)