Files
heros_web/scripts/build-exploration-character-sheet.py

152 lines
4.4 KiB
Python

#!/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()