feat: animate tutorial battle characters
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the P9.0R2 runtime animation tree from accepted source frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT = ROOT / "runtime/assets/animations"
|
||||
GEN = ROOT / "docs/prototype/generation/phase9_0r2_character_animation_01/runs"
|
||||
PHASE6 = ROOT / "docs/prototype/generation/phase6_motion_proof_03/runs"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def copy_frame(source: Path, target: Path, records: list[dict[str, object]]) -> None:
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"missing animation source: {source}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
records.append(
|
||||
{
|
||||
"source": source.relative_to(ROOT).as_posix(),
|
||||
"runtime": target.relative_to(ROOT).as_posix(),
|
||||
"sha256": sha256(target),
|
||||
"width": Image.open(target).width,
|
||||
"height": Image.open(target).height,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def copy_cycle(
|
||||
character: str,
|
||||
state: str,
|
||||
source_root: Path,
|
||||
frame_names: list[str],
|
||||
records: list[dict[str, object]],
|
||||
) -> None:
|
||||
for index, name in enumerate(frame_names):
|
||||
copy_frame(
|
||||
source_root / name,
|
||||
OUT / character / state / f"frame-{index}.png",
|
||||
records,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
records: list[dict[str, object]] = []
|
||||
|
||||
hearthguard = PHASE6 / "recruit-aegis/accepted"
|
||||
for state in ("idle", "advance", "guard", "basic_attack", "victory"):
|
||||
manifest_path = hearthguard / state / "cycle-manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
copy_cycle(
|
||||
"hearthguard",
|
||||
state,
|
||||
hearthguard / state,
|
||||
[str(frame["output"]) for frame in manifest["frames"]],
|
||||
records,
|
||||
)
|
||||
|
||||
riftblade = PHASE6 / "enemy-riftblade/accepted"
|
||||
for state in ("idle", "advance", "telegraph", "attack", "hit", "death"):
|
||||
manifest_path = riftblade / state / "cycle-manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
copy_cycle(
|
||||
"riftblade",
|
||||
state,
|
||||
riftblade / state,
|
||||
[str(frame["output"]) for frame in manifest["frames"]],
|
||||
records,
|
||||
)
|
||||
|
||||
custom = {
|
||||
"wayrunner": {
|
||||
"ready": GEN / "wayrunner-r2/curated/ready.png",
|
||||
"advance_1": GEN / "wayrunner-r2/curated/advance_1.png",
|
||||
"advance_2": GEN / "wayrunner-r2/curated/advance_2.png",
|
||||
"evade": GEN / "wayrunner-r2/curated/evade.png",
|
||||
"counter": GEN / "wayrunner-r2/curated/counter.png",
|
||||
"recover": GEN / "wayrunner-r2/curated/recover.png",
|
||||
},
|
||||
"farstring": {
|
||||
"ready": GEN / "farstring/curated/ready.png",
|
||||
"advance_1": GEN / "farstring/curated/advance_1.png",
|
||||
"raise": GEN / "farstring/curated/raise.png",
|
||||
"draw": GEN / "farstring/curated/draw.png",
|
||||
"release": GEN / "farstring/curated/release.png",
|
||||
},
|
||||
"kindhand": {
|
||||
"ready": GEN / "kindhand/curated/ready.png",
|
||||
"advance_1": GEN / "kindhand/curated/advance_1.png",
|
||||
"advance_2": GEN / "kindhand/curated/advance_2.png",
|
||||
"raise": GEN / "kindhand/curated/raise.png",
|
||||
"heal": GEN / "kindhand/curated/heal.png",
|
||||
"recover": GEN / "kindhand/curated/recover.png",
|
||||
},
|
||||
}
|
||||
runtime_sequences = {
|
||||
"wayrunner": {
|
||||
"idle": ["ready", "recover"],
|
||||
"advance": ["advance_1", "advance_2"],
|
||||
"evade_counter": ["ready", "evade", "counter", "recover"],
|
||||
},
|
||||
"farstring": {
|
||||
"idle": ["ready", "raise"],
|
||||
"advance": ["ready", "advance_1"],
|
||||
"draw_release": ["ready", "raise", "draw", "release"],
|
||||
},
|
||||
"kindhand": {
|
||||
"idle": ["ready", "recover"],
|
||||
"advance": ["advance_1", "advance_2"],
|
||||
"lantern_heal": ["ready", "raise", "heal", "recover"],
|
||||
},
|
||||
}
|
||||
for character, states in runtime_sequences.items():
|
||||
for state, names in states.items():
|
||||
for index, name in enumerate(names):
|
||||
copy_frame(
|
||||
custom[character][name],
|
||||
OUT / character / state / f"frame-{index}.png",
|
||||
records,
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"kind": "aetherbound-p9-0r2-runtime-animation-assets",
|
||||
"generated_calls": 4,
|
||||
"generated_cost_usd": 0.056,
|
||||
"rejected": [
|
||||
{
|
||||
"run": "wayrunner",
|
||||
"reason": "detached duplicate blade fragment and missing held dagger at counter impact",
|
||||
}
|
||||
],
|
||||
"records": records,
|
||||
}
|
||||
(OUT / "asset-manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"ABG_P9_0R2_ANIMATION_ASSETS_OK files={len(records)} calls=4 cost_usd=0.056")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify P9.0R2 animation provenance, frame integrity and state diversity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST = ROOT / "runtime/assets/animations/asset-manifest.json"
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
payload = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
records = payload["records"]
|
||||
if len(records) != 92:
|
||||
raise SystemExit(f"expected 92 records, found {len(records)}")
|
||||
states: dict[str, list[Path]] = defaultdict(list)
|
||||
for record in records:
|
||||
source = ROOT / record["source"]
|
||||
runtime = ROOT / record["runtime"]
|
||||
if not source.is_file() or not runtime.is_file():
|
||||
raise SystemExit(f"missing source/runtime pair: {record}")
|
||||
if sha256(source) != record["sha256"] or sha256(runtime) != record["sha256"]:
|
||||
raise SystemExit(f"hash mismatch: {record['runtime']}")
|
||||
with Image.open(runtime) as image:
|
||||
if image.mode != "RGBA" or image.getbbox() is None:
|
||||
raise SystemExit(f"invalid transparent animation frame: {record['runtime']}")
|
||||
if image.width != record["width"] or image.height != record["height"]:
|
||||
raise SystemExit(f"dimension mismatch: {record['runtime']}")
|
||||
relative = Path(record["runtime"])
|
||||
states[(relative.parts[-3] + "/" + relative.parts[-2])].append(runtime)
|
||||
|
||||
for state, frames in states.items():
|
||||
if len(frames) < 2:
|
||||
raise SystemExit(f"state has fewer than two frames: {state}")
|
||||
with Image.open(frames[0]) as first:
|
||||
first_rgba = first.convert("RGBA")
|
||||
distinct = False
|
||||
for path in frames[1:]:
|
||||
with Image.open(path) as image:
|
||||
candidate = image.convert("RGBA")
|
||||
if candidate.size != first_rgba.size or ImageChops.difference(first_rgba, candidate).getbbox() is not None:
|
||||
distinct = True
|
||||
break
|
||||
if not distinct:
|
||||
raise SystemExit(f"state frames are visually identical: {state}")
|
||||
|
||||
if payload["generated_calls"] != 4 or abs(payload["generated_cost_usd"] - 0.056) > 0.000001:
|
||||
raise SystemExit("generation cost/call provenance mismatch")
|
||||
if payload["rejected"] != [
|
||||
{
|
||||
"run": "wayrunner",
|
||||
"reason": "detached duplicate blade fragment and missing held dagger at counter impact",
|
||||
}
|
||||
]:
|
||||
raise SystemExit("rejected generation history mismatch")
|
||||
print(f"ABG_P9_0R2_ANIMATION_ASSETS_OK files={len(records)} states={len(states)} calls=4 cost_usd=0.056")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user