art: prove crowded battle motion scale
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user