Files
splatter-tennis/assets/_fine.py
T
wuwiandClaude Opus 5 c89d4a3ff9 Three modes, picked up front — and CLASSIC TENNIS is one of them
The setup screen picks WHAT before WHO and WHERE. Three rows out of a table,
in the court row's own markup:

CLASSIC TENNIS is not the opener with the turn switched off and left there —
it is the mode the other two are built on, so it gets real match scoring: six
games with a two-game lead is a set, best of TUNE.classicSets is the match,
the board shows the sets column (hidden until now outside splatter) and the
bar under it says which set this is. The CPU stops rubber-banding and flubs
less: aiFlubLoss exists to get a new player PAST the opener, and a mode whose
whole promise is a real match cannot be won by an opponent that falls apart
the moment he goes ahead. No tiebreak — 6-6 plays on to 8-6. Marked ponytail.

That needed a second ENDING, the one with no corpse in it: G.done, the same
summary and gated tap as the death screen, its own words (GAME, SET, MATCH. /
BEATEN.) and the man still standing. The three endings now share one block
rather than three copies of it.

Caught by the first classic test: stats banked only the FIRST classic run of a
session. `banked` was cleared by the next KILL, and a classic match has no
kills in it at all — every later match was swallowed by the double-bank guard.
beginRun() is the honest reset and restartRun calls it.

ZOMBIE TENNIS from the menu is one line; the back room keeps its door.

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

134 lines
6.7 KiB
Python

# _fine.py — turn a generated sheet into FULL-RESOLUTION sprites.
#
# _slice.py resamples with NEAREST, which is right when the generator's art-pixel grid is coarse but
# shreds fine detail at a non-integer ratio. This one instead:
# 1. detects the art-pixel BLOCK SIZE the generator actually drew at, and snaps to that grid
# (one output pixel per art pixel — no half-blocks, no broken outlines),
# 2. common-scales the sheet to the target height with a box filter,
# 3. quantises every pixel back to the sheet's own palette, so the result is flat pixel art
# rather than a blurred photo of pixel art.
# Usage: _fine.py sheet.png out1.png out2.png ... [--h 224] [--cut X,Y,...]
import sys
from PIL import Image, ImageDraw
import numpy as np
TARGET_H = 224
KEY_D2 = 60 * 60
MIN_GAP = 12
MIN_BLOB = 60
def key(im, defringe=2):
a = np.array(im.convert('RGBA'))
bg = a[0, 0, :3].astype(int)
d = ((a[..., :3].astype(int) - bg) ** 2).sum(2)
magenta = np.minimum(a[..., 0], a[..., 2]).astype(int) > a[..., 1].astype(int) + 40
bgish = (d < KEY_D2) | magenta
# ONLY THE OUTSIDE IS BACKGROUND. This used to punch out every magenta pixel on the sheet
# wherever it was, and the generator paints a racket's STRINGS in the backdrop colour more often
# than not — so frame after frame installed a man holding an empty hoop. Background is the region
# CONNECTED TO THE BORDER (which touches_edge already guarantees no figure reaches), so an
# enclosed patch of it inside a racket head is not background and does not become a hole.
# .copy() is load-bearing: an Image.fromarray image is READ-ONLY, and ImageDraw.floodfill's
# first write raises inside its own try/except and returns having filled nothing — a silent
# no-op that looks exactly like "the whole sheet is one figure".
m = Image.fromarray(np.where(bgish, 255, 0).astype('uint8'), 'L').copy()
ImageDraw.floodfill(m, (0, 0), 128)
outside = np.array(m) == 128
if not outside.any(): sys.exit('ERROR: background flood filled nothing — is (0,0) not background?')
a[outside] = 0
# ...and what that leaves — magenta INSIDE a body — is repainted as pale grey rather than kept,
# because bright pink strings are as wrong as no strings. Luminance is preserved, so the drawn
# crosshatch survives as crosshatch; only the hue goes.
inner = magenta & ~outside
if inner.any():
lum = a[..., :3].mean(2)
a[inner, 0] = a[inner, 1] = a[inner, 2] = np.clip(lum[inner] * 0.5 + 115, 0, 255).astype('uint8')
# De-fringe. The generator does not always give you a pure key colour, and whatever it does give
# bleeds a halo one pixel into the sprite — which survives as a magenta rim once the sprite is on
# a green court. Anything on the EDGE that is still leaning towards the background goes too.
# ...but each pass also eats a 1-pixel line, and a racket frame IS a 1-pixel line: two passes
# removed the rackets from a whole serve sheet and left men holding a grip. --defringe 1 (or 0)
# is for sheets whose art is that thin.
for _ in range(defringe):
solid = a[..., 3] > 0
edge = solid & ~(np.roll(solid, 1, 0) & np.roll(solid, -1, 0) & np.roll(solid, 1, 1) & np.roll(solid, -1, 1))
near = ((a[..., :3].astype(int) - bg) ** 2).sum(2) < KEY_D2 * 9
a[edge & near] = 0
return a
def block_size(a):
ink = a[..., 3] > 0
ys, xs = np.where(ink)
sub = a[ys.min():ys.max()+1, xs.min():xs.max()+1]
h, w = sub.shape[:2]
best = 1
for b in range(2, 13):
if h < b * 6: break
ok = tot = 0
for y in range(0, h - b, b):
for x in range(0, w - b, b):
blk = sub[y:y+b, x:x+b].reshape(-1, 4)
tot += 1
if (blk == blk[0]).all(): ok += 1
if tot and ok / tot > 0.82: best = b
return best
def frames(a, cuts):
ink = (a[..., 3] > 0)
cols = ink.any(0)
out, start, gap = [], None, 0
for x in range(len(cols)):
if cols[x]:
if start is None: start = x
gap = 0
elif start is not None:
gap += 1
if gap >= MIN_GAP: out.append((start, x - gap + 1)); start = None
if start is not None: out.append((start, len(cols)))
for c in sorted(cuts, reverse=True): # manual splits where two figures touch
for i, (x0, x1) in enumerate(out):
if x0 < c < x1: out[i:i+1] = [(x0, c), (c, x1)]; break
return out
def main(sheet, outs, target_h, cuts, anchor, defringe=2):
a = key(Image.open(sheet), defringe)
b = block_size(a)
crops = []
for x0, x1 in frames(a, cuts):
sub = a[:, x0:x1]
ys = np.where((sub[..., 3] > 0).any(1))[0]; xs = np.where((sub[..., 3] > 0).any(0))[0]
crops.append(sub[ys.min():ys.max()+1, xs.min():xs.max()+1])
if len(crops) != len(outs):
sys.exit(f'ERROR: found {len(crops)} frames, expected {len(outs)} ({sheet})')
# palette = the colours the generator actually used, on the largest frame
big = max(crops, key=lambda c: (c[..., 3] > 0).sum())
px = big[big[..., 3] > 200][:, :3]
pal = np.unique((px // 8) * 8, axis=0)
# ONE scale for the sheet, anchored on a chosen frame — the STANDING pose is the 224 reference,
# so a smash with the arm overhead stays legitimately taller (the game scales off img.height).
anchor_h = crops[anchor].shape[0] if 0 <= anchor < len(crops) else max(c.shape[0] for c in crops)
k = target_h / (anchor_h / b)
for c, out in zip(crops, outs):
h, w = c.shape[:2]
snap = Image.fromarray(c, 'RGBA').resize((max(1, round(w / b)), max(1, round(h / b))), Image.BOX)
fh = max(1, round(snap.height * k)); fw = max(1, round(snap.width * k))
im = snap.resize((fw, fh), Image.BOX)
arr = np.array(im).astype(int)
solid = arr[..., 3] > 110
flat = arr[..., :3].reshape(-1, 3)
idx = ((flat[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1)
arr[..., :3] = pal[idx].reshape(arr.shape[0], arr.shape[1], 3)
arr[..., 3] = np.where(solid, 255, 0)
Image.fromarray(arr.astype('uint8'), 'RGBA').save(out)
print(f'{out}: {fw}x{fh} (art block {b} in source)')
if __name__ == '__main__':
args = sys.argv[1:]
th, cuts, anchor = TARGET_H, [], -1
if '--h' in args: i = args.index('--h'); th = int(args[i+1]); del args[i:i+2]
if '--cut' in args: i = args.index('--cut'); cuts = [int(v) for v in args[i+1].split(',')]; del args[i:i+2]
de = 2
if '--defringe' in args: i = args.index('--defringe'); de = int(args[i+1]); del args[i:i+2]
if '--anchor' in args: i = args.index('--anchor'); anchor = int(args[i+1]); del args[i:i+2]
main(args[0], args[1:], th, cuts, anchor, de)