Files
aetherbound-guild/tools/build_battle_scale_motion_proof.py
T

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()