"""Normalize generated art offline; all toss animation and play are MASM.""" import hashlib import json from collections import deque from pathlib import Path from PIL import Image import pack_arrival_assets as packs ROOT = Path(__file__).resolve().parents[2] SOURCE = ROOT / "assets/sprites/coin" def isolated_hand(frame): """Remove disconnected previous-row cuff spill without changing the hand anchor.""" width, height = frame.size alpha = frame.getchannel("A").tobytes() visited = bytearray(width * height) largest = [] for seed, value in enumerate(alpha): if not value or visited[seed]: continue visited[seed] = 1 queue = deque([seed]) component = [] while queue: at = queue.popleft() component.append(at) x, y = at % width, at // width for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)): nx, ny = x + dx, y + dy if 0 <= nx < width and 0 <= ny < height: neighbor = ny * width + nx if alpha[neighbor] and not visited[neighbor]: visited[neighbor] = 1 queue.append(neighbor) if len(component) > len(largest): largest = component mask = bytearray(width * height) for at in largest: mask[at] = 255 frame.putalpha(Image.frombytes("L", frame.size, bytes(mask))) return frame def main(): packs.OUTPUT = ROOT / "assets/animations/coin" preview = ROOT / "build/coin" preview.mkdir(parents=True, exist_ok=True) background = Image.open(SOURCE / "table-v1.png").convert("RGBA") background = background.resize((640, 480), Image.Resampling.NEAREST) # Reserve the renderer's 40-pixel header without cropping the dealer's head. framed = Image.new("RGBA", (640, 480), (20, 25, 29, 255)) framed.paste(background.crop((0, 0, 640, 440)), (0, 40)) background = framed background.putalpha(255) packs.write_pack("table-v1.bin", [background]) background.save(preview / "table.png") source = Image.open(SOURCE / "faces-v1.png").convert("RGBA") assert source.getchannel("A").getextrema()[0] == 0 faces = [] for column in range(2): frame = source.crop((column * source.width // 2, 0, (column + 1) * source.width // 2, source.height)) frame = frame.crop(frame.getchannel("A").getbbox()) frame = frame.resize((128, 128), Image.Resampling.NEAREST) frame = packs.solid_alpha(frame) faces.append(frame) frame.save(preview / ("face-" + str(column) + ".png")) packs.write_pack("faces-v1.bin", faces) source = Image.open(SOURCE / "hands-v2.png").convert("RGBA") assert source.getchannel("A").getextrema()[0] == 0 hands = [] held_centers = [(79, 70), (76, 77), (74, 82), (77, 96), (70, 98)] for row in range(4): for column in range(4): frame = source.crop((column * source.width // 4, row * source.height // 4, (column + 1) * source.width // 4, (row + 1) * source.height // 4)) # Keep common cell scale rather than stretching each pose's alpha bounds. frame = frame.resize((224, 224), Image.Resampling.NEAREST) frame = packs.solid_alpha(frame) # Remove the generator's saturated red alpha-matte fringe, not skin highlights. frame.putdata([(r, g, b, 0 if r > 120 and g < 50 and b < 50 else a) for r, g, b, a in frame.get_flattened_data()]) index = len(hands) if index >= len(held_centers): frame = isolated_hand(frame) if index < len(held_centers): # The held coin and fingers share one sprite and the release-point anchor. cx, cy = held_centers[index] aligned = Image.new("RGBA", (224, 224)) aligned.paste(frame, (88 - cx, 24 - cy)) frame = aligned frame.putdata([(r, g, b, a) if a else (0, 0, 0, 0) for r, g, b, a in frame.get_flattened_data()]) assert frame.getchannel("A").getbbox() hands.append(frame) frame.save(preview / ("hand-" + str(len(hands) - 1) + ".png")) assert len({hashlib.sha256(frame.tobytes()).hexdigest() for frame in hands}) == 16 packs.write_pack("hands-v2.bin", hands) (packs.OUTPUT / "manifest-v1.json").write_text(json.dumps({ "table": [640, 480, 1], "faces": [128, 128, 2], "hands": [224, 224, 16], "hand_anchor": "held coin (88,24); empty hand shared grid cell", "held_frames": 5, "alpha": "opaque table, binary cutouts" }, indent=2) + "\n", encoding="ascii") print("Packed coin table, two embossed sides and sixteen distinct hand poses") if __name__ == "__main__": main()