#!/usr/bin/env python3 """Build a four-direction exploration sprite sheet from a three-view turnaround. The input is expected to contain transparent front, right, and back views in three equal horizontal columns. The output matches the runtime exploration contract: 192px frames, four rows (south/east/north/west), and sixteen frames per row (eight idle followed by eight walk frames). """ from __future__ import annotations import argparse from pathlib import Path from PIL import Image FRAME_SIZE = 192 IDLE_FRAME_COUNT = 8 WALK_FRAME_COUNT = 8 FRAMES_PER_DIRECTION = IDLE_FRAME_COUNT + WALK_FRAME_COUNT DIRECTIONS = ("south", "east", "north", "west") IDLE_OFFSETS = ( (0, 0), (0, -1), (0, -2), (0, -1), (0, 0), (0, 1), (0, 0), (0, -1), ) WALK_OFFSETS = ( (-2, 0), (-1, -2), (0, -3), (1, -1), (2, 0), (1, -2), (0, -3), (-1, -1), ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Build a 3072x768 exploration SD character sheet." ) parser.add_argument("--input", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument( "--max-height", type=int, default=184, help="Maximum character height inside each 192px frame.", ) parser.add_argument( "--max-width", type=int, default=172, help="Maximum character width inside each 192px frame.", ) return parser.parse_args() def split_turnaround(source: Image.Image) -> tuple[Image.Image, Image.Image, Image.Image]: views: list[Image.Image] = [] for index in range(3): left = round(source.width * index / 3) right = round(source.width * (index + 1) / 3) view = source.crop((left, 0, right, source.height)) alpha = view.getchannel("A") bbox = alpha.getbbox() if not bbox: raise ValueError(f"Turnaround column {index + 1} contains no visible pixels.") views.append(view.crop(bbox)) return views[0], views[1], views[2] def resize_views( views: tuple[Image.Image, Image.Image, Image.Image], max_width: int, max_height: int, ) -> tuple[Image.Image, Image.Image, Image.Image]: widest = max(view.width for view in views) tallest = max(view.height for view in views) scale = min(max_width / widest, max_height / tallest) if scale <= 0: raise ValueError("The requested sprite bounds must be positive.") return tuple( view.resize( (max(1, round(view.width * scale)), max(1, round(view.height * scale))), Image.Resampling.LANCZOS, ) for view in views ) def place_frame(view: Image.Image, offset: tuple[int, int]) -> Image.Image: frame = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0)) x = (FRAME_SIZE - view.width) // 2 + offset[0] y = FRAME_SIZE - 4 - view.height + offset[1] frame.alpha_composite(view, (x, y)) return frame def build_sheet(source: Image.Image, max_width: int, max_height: int) -> Image.Image: front, right, back = resize_views( split_turnaround(source), max_width=max_width, max_height=max_height, ) directional_views = { "south": front, "east": right, "north": back, "west": right.transpose(Image.Transpose.FLIP_LEFT_RIGHT), } sheet = Image.new( "RGBA", (FRAME_SIZE * FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0), ) for row, direction in enumerate(DIRECTIONS): view = directional_views[direction] offsets = (*IDLE_OFFSETS, *WALK_OFFSETS) for column, offset in enumerate(offsets): sheet.alpha_composite( place_frame(view, offset), (column * FRAME_SIZE, row * FRAME_SIZE), ) return sheet def main() -> None: args = parse_args() source = Image.open(args.input).convert("RGBA") if source.getchannel("A").getextrema()[0] != 0: raise ValueError("The turnaround must contain transparent background pixels.") sheet = build_sheet(source, max_width=args.max_width, max_height=args.max_height) args.output.parent.mkdir(parents=True, exist_ok=True) sheet.save(args.output, "WEBP", lossless=True, method=6) print( f"Wrote {args.output} " f"({sheet.width}x{sheet.height}, {FRAMES_PER_DIRECTION} frames x {len(DIRECTIONS)} rows)." ) if __name__ == "__main__": main()