155 lines
4.6 KiB
Python
155 lines
4.6 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, screen-right-facing, and
|
|
back views in three equal horizontal columns. In the middle view, the face and
|
|
feet must point toward the right edge of the image; an anatomical-right profile
|
|
that points screen-left must be mirrored before building. 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()
|