feat: animate tutorial battle characters

This commit is contained in:
2026-08-12 22:18:20 +08:00
parent 9f26320d51
commit 6c72e243c2
301 changed files with 7126 additions and 19 deletions
+159
View File
@@ -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())
+72
View File
@@ -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())