fix: honor non-loop battle animations

This commit is contained in:
2026-08-12 07:43:12 +08:00
parent 7ea57284cb
commit b44c29fa2f
3 changed files with 89 additions and 4 deletions
+46 -3
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
from functools import lru_cache
import hashlib
import json
from dataclasses import dataclass
@@ -50,6 +51,28 @@ def frame_files(state_dir: Path) -> list[Path]:
return files
@lru_cache(maxsize=None)
def animation_contract(state_dir: Path) -> tuple[str, bool]:
manifest_path = state_dir.parent / "cycle-manifest.json"
if not manifest_path.is_file():
raise SystemExit(f"missing cycle manifest: {manifest_path}")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
frames = frame_files(state_dir)
if len(manifest.get("frames", [])) != len(frames):
raise SystemExit(f"cycle manifest frame mismatch: {manifest_path}")
return str(manifest["state"]), bool(manifest["loop"])
def selected_frame(actor: Actor, tick: int) -> tuple[Path, str, bool, int]:
frames = frame_files(actor.state_dir)
state, loop = animation_contract(actor.state_dir)
if loop:
frame_index = (tick + actor.phase) % len(frames)
else:
frame_index = min(max(tick - actor.phase, 0), len(frames) - 1)
return frames[frame_index], state, loop, frame_index
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")
@@ -64,8 +87,7 @@ def fitted_sprite(path: Path, target_height: int) -> tuple[Image.Image, tuple[in
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)]
source, state, loop, frame_index = selected_frame(actor, tick)
sprite, source_bbox = fitted_sprite(source, actor.height)
left = round(actor.x - sprite.width / 2)
top = actor.bottom - sprite.height
@@ -76,6 +98,10 @@ def paste_actor(canvas: Image.Image, actor: Actor, tick: int) -> dict[str, objec
canvas.alpha_composite(sprite, (left, top))
return {
"side": actor.side,
"state": state,
"loop": loop,
"animation_tick": tick,
"frame_index": frame_index,
"source": str(source),
"source_sha256": sha256(source),
"source_alpha_bbox": list(source_bbox),
@@ -200,7 +226,24 @@ def main() -> None:
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))
if actor.high_salience:
state = "telegraph" if tick < 6 else "attack"
state_dir = repo / (
"docs/prototype/generation/phase6_motion_proof_03/runs/"
f"enemy-riftblade/accepted/{state}/frames"
)
actor = Actor(
actor.side,
state_dir,
actor.x,
actor.bottom,
actor.height,
0,
actor.high_salience,
)
actor_reports.append(paste_actor(canvas, actor, tick if tick < 6 else tick - 6))
else:
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, True)