222 lines
8.3 KiB
Python
222 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Compose accepted Phase 6 sprites into a deterministic battle-scale proof."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
|
|
CANVAS = (2048, 1152)
|
|
FRAME_COUNT = 16
|
|
FPS = 8
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Actor:
|
|
side: str
|
|
state_dir: Path
|
|
x: int
|
|
bottom: int
|
|
height: int
|
|
phase: int
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--background", type=Path, required=True)
|
|
parser.add_argument("--out-dir", type=Path, required=True)
|
|
return parser.parse_args()
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def frame_files(state_dir: Path) -> list[Path]:
|
|
files = sorted(state_dir.glob("frame-*.png"), key=lambda path: int(path.stem.split("-")[-1]))
|
|
if not files:
|
|
raise SystemExit(f"no frames found: {state_dir}")
|
|
return files
|
|
|
|
|
|
def fitted_sprite(path: Path, target_height: int) -> tuple[Image.Image, tuple[int, int, int, int]]:
|
|
source = Image.open(path).convert("RGBA")
|
|
alpha = source.getchannel("A")
|
|
bbox = alpha.point(lambda value: 255 if value >= 16 else 0).getbbox()
|
|
if bbox is None:
|
|
raise SystemExit(f"empty alpha frame: {path}")
|
|
cropped = source.crop(bbox)
|
|
scale = target_height / cropped.height
|
|
width = max(1, round(cropped.width * scale))
|
|
resized = cropped.resize((width, target_height), Image.Resampling.LANCZOS)
|
|
return resized, bbox
|
|
|
|
|
|
def paste_actor(canvas: Image.Image, actor: Actor, tick: int) -> dict[str, object]:
|
|
frames = frame_files(actor.state_dir)
|
|
source = frames[(tick + actor.phase) % len(frames)]
|
|
sprite, source_bbox = fitted_sprite(source, actor.height)
|
|
left = round(actor.x - sprite.width / 2)
|
|
top = actor.bottom - sprite.height
|
|
right = left + sprite.width
|
|
bottom = top + sprite.height
|
|
if left < 0 or top < 96 or right > CANVAS[0] or bottom > 944:
|
|
raise SystemExit(f"actor outside battle safe region: {actor} -> {(left, top, right, bottom)}")
|
|
canvas.alpha_composite(sprite, (left, top))
|
|
return {
|
|
"side": actor.side,
|
|
"source": str(source),
|
|
"source_sha256": sha256(source),
|
|
"source_alpha_bbox": list(source_bbox),
|
|
"content_rect": [left, top, right, bottom],
|
|
"content_height": actor.height,
|
|
}
|
|
|
|
|
|
def draw_hud(canvas: Image.Image) -> None:
|
|
draw = ImageDraw.Draw(canvas, "RGBA")
|
|
ink = (24, 32, 45, 220)
|
|
paper = (244, 240, 224, 235)
|
|
blue = (70, 158, 217, 255)
|
|
coral = (218, 77, 79, 255)
|
|
gold = (194, 151, 67, 255)
|
|
draw.rounded_rectangle((32, 24, 650, 92), radius=6, fill=ink, outline=gold, width=3)
|
|
draw.rounded_rectangle((1398, 24, 2016, 92), radius=6, fill=ink, outline=gold, width=3)
|
|
for index in range(10):
|
|
x = 86 + index * 52
|
|
draw.polygon([(x, 42), (x + 13, 55), (x, 68), (x - 13, 55)], fill=blue)
|
|
for index in range(12):
|
|
x = 1452 + index * 45
|
|
draw.polygon([(x, 42), (x + 12, 54), (x, 66), (x - 12, 54)], fill=coral)
|
|
draw.rounded_rectangle((410, 1010, 1638, 1132), radius=5, fill=ink, outline=gold, width=3)
|
|
for index in range(10):
|
|
left = 438 + index * 118
|
|
draw.rounded_rectangle((left, 1028, left + 92, 1112), radius=4, fill=paper, outline=gold, width=2)
|
|
draw.rectangle((left + 10, 1092, left + 82, 1102), fill=(76, 164, 101, 255))
|
|
|
|
|
|
def actors(repo: Path) -> tuple[list[Actor], list[Actor], Actor]:
|
|
motion = repo / "docs/prototype/generation/phase6_motion_proof_03/runs"
|
|
recruit = motion / "recruit-aegis"
|
|
enemy = motion / "enemy-riftblade"
|
|
boss = motion / "boss-tempest-seraph"
|
|
|
|
ally_states = [
|
|
recruit / "accepted/guard/frames",
|
|
recruit / "accepted-r2/advance/frames",
|
|
recruit / "accepted/basic_attack/frames",
|
|
recruit / "accepted/idle/frames",
|
|
recruit / "accepted-r2/profession_action/frames",
|
|
recruit / "accepted-r2/advance/frames",
|
|
recruit / "accepted/guard/frames",
|
|
recruit / "accepted/hit/frames",
|
|
recruit / "accepted/basic_attack/frames",
|
|
recruit / "accepted/idle/frames",
|
|
]
|
|
allies = [
|
|
Actor("ally", state, 112 + index * 94, 854 - (index % 2) * 10, 136 + (index % 3) * 6, index * 2)
|
|
for index, state in enumerate(ally_states)
|
|
]
|
|
|
|
enemy_states = [
|
|
enemy / "accepted/telegraph/frames",
|
|
enemy / "accepted/attack/frames",
|
|
enemy / "accepted/idle/frames",
|
|
enemy / "accepted/advance/frames",
|
|
enemy / "accepted/hit/frames",
|
|
]
|
|
enemies: list[Actor] = []
|
|
for index in range(12):
|
|
depth = index % 3
|
|
enemies.append(
|
|
Actor(
|
|
"enemy",
|
|
enemy_states[index % len(enemy_states)],
|
|
1120 + index * 48,
|
|
838 - depth * 42,
|
|
124 + (index % 4) * 5,
|
|
index,
|
|
)
|
|
)
|
|
boss_actor = Actor("boss", boss / "accepted/telegraph/frames", 1858, 842, 302, 0)
|
|
return allies, enemies, boss_actor
|
|
|
|
|
|
def make_contact(paths: list[Path], output: Path) -> None:
|
|
thumbs: list[Image.Image] = []
|
|
for path in paths:
|
|
image = Image.open(path).convert("RGB")
|
|
thumbs.append(image.resize((384, 216), Image.Resampling.LANCZOS))
|
|
sheet = Image.new("RGB", (1920, 216), (20, 24, 30))
|
|
for index, image in enumerate(thumbs):
|
|
sheet.paste(image, (index * 384, 0))
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
sheet.save(output, optimize=True)
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
repo = Path(__file__).resolve().parents[1]
|
|
if not args.background.is_file():
|
|
raise SystemExit(f"missing background: {args.background}")
|
|
args.out_dir.mkdir(parents=True, exist_ok=True)
|
|
frames_dir = args.out_dir / "frames"
|
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
background = Image.open(args.background).convert("RGB").resize(CANVAS, Image.Resampling.LANCZOS)
|
|
allies, enemies, boss_template = actors(repo)
|
|
frame_reports: list[dict[str, object]] = []
|
|
frame_paths: list[Path] = []
|
|
|
|
for tick in range(FRAME_COUNT):
|
|
canvas = background.convert("RGBA")
|
|
actor_reports: list[dict[str, object]] = []
|
|
for actor in sorted(enemies, key=lambda item: item.bottom):
|
|
actor_reports.append(paste_actor(canvas, actor, tick))
|
|
boss_state = "telegraph" if tick < 8 else "attack"
|
|
boss_dir = repo / f"docs/prototype/generation/phase6_motion_proof_03/runs/boss-tempest-seraph/accepted/{boss_state}/frames"
|
|
boss_actor = Actor("boss", boss_dir, boss_template.x, boss_template.bottom, boss_template.height, 0)
|
|
actor_reports.append(paste_actor(canvas, boss_actor, tick % 8))
|
|
for actor in sorted(allies, key=lambda item: item.bottom):
|
|
actor_reports.append(paste_actor(canvas, actor, tick))
|
|
draw_hud(canvas)
|
|
output = frames_dir / f"frame-{tick:03d}.png"
|
|
canvas.convert("RGB").save(output, optimize=True)
|
|
frame_paths.append(output)
|
|
frame_reports.append({"frame": tick, "actors": actor_reports, "sha256": sha256(output)})
|
|
|
|
opening = args.out_dir / "battle-scale-opening.png"
|
|
opening.write_bytes(frame_paths[0].read_bytes())
|
|
contact = args.out_dir / "battle-scale-contact.png"
|
|
make_contact([frame_paths[index] for index in (0, 4, 8, 12, 15)], contact)
|
|
audit = {
|
|
"version": 1,
|
|
"kind": "aetherbound-battle-scale-motion-proof",
|
|
"canvas": list(CANVAS),
|
|
"fps": FPS,
|
|
"frames": FRAME_COUNT,
|
|
"duration_seconds": FRAME_COUNT / FPS,
|
|
"background": {"path": str(args.background), "sha256": sha256(args.background)},
|
|
"counts_per_frame": {"allies": 10, "ordinary_enemies": 12, "bosses": 1},
|
|
"height_contract": {"allies": [130, 155], "ordinary_enemies": [120, 145], "boss": [280, 315]},
|
|
"frame_reports": frame_reports,
|
|
"opening_sha256": sha256(opening),
|
|
"contact_sha256": sha256(contact),
|
|
}
|
|
(args.out_dir / "audit.json").write_text(json.dumps(audit, ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps({"ok": True, "frames": FRAME_COUNT, "fps": FPS, "out_dir": str(args.out_dir)}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|