feat: add first-victory camp exploration
This commit is contained in:
151
scripts/build-exploration-character-sheet.py
Normal file
151
scripts/build-exploration-character-sheet.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user