fix: honor non-loop battle animations
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# Phase 6 Battle-Scale Motion Proof 04
|
||||
|
||||
Date: 2026-08-12
|
||||
Stage: `BATTLE_SCALE_CANDIDATE_READY`
|
||||
Stage: `MOTION_CONTINUITY_REPAIR_OPEN`
|
||||
Motion authority: `../phase6_motion_proof_03/`
|
||||
Style authority: `../phase6_ui_direction_01/outputs/05_battle_ui_japanese_scale.png`
|
||||
|
||||
@@ -61,3 +61,18 @@ canvas boundaries and output hashes.
|
||||
|
||||
This proof can reach `processed` and `agent_visual_reviewed` only. Human visual,
|
||||
engine runtime, touch, audio, device and release gates remain open.
|
||||
|
||||
## Independent Review R2 Failure
|
||||
|
||||
Fixed review of `1304d52bef0b0cb0a43635590ad8c18e888ffa04` passed
|
||||
counts, scale, bounds, salience, inventory and background gates, then failed at
|
||||
the earliest motion-continuity boundary. The compositor used modulo indexing
|
||||
for the enemy Riftblade's `loop:false` telegraph and reset frame 5 to frame 0
|
||||
at output transitions 005->006 and 011->012 instead of entering attack.
|
||||
|
||||
Forward repair must read every cycle manifest's `loop` flag. Looping cycles may
|
||||
retain phase-offset modulo selection. Non-looping allied actions use their
|
||||
declared phase as a start delay and clamp at the terminal frame. The one
|
||||
high-salience enemy must play telegraph 0-5 once, attack 0-5 once, then hold its
|
||||
terminal attack frame. Accepted sprite sources, counts, positions, scale,
|
||||
background, HUD and salience limits remain unchanged.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Phase 6 Battle-Scale Motion Proof 04 Review R2
|
||||
|
||||
Date: 2026-08-12
|
||||
Fixed revision: `1304d52bef0b0cb0a43635590ad8c18e888ffa04`
|
||||
Verdict: `FAIL`
|
||||
|
||||
## Earliest Failure
|
||||
|
||||
The Riftblade telegraph manifest declares `loop: false` and ends in an
|
||||
attack-ready crouch. The compositor nevertheless selected every state with
|
||||
modulo indexing while leaving the high-salience enemy in telegraph for all 16
|
||||
frames. Output transitions 005->006 and 011->012 reset telegraph frame 5 to
|
||||
frame 0 instead of transitioning into attack, creating a visible state-reset
|
||||
seam introduced by composition.
|
||||
|
||||
## Gates Passed Before Failure
|
||||
|
||||
- immutable fixed archive and source inventories;
|
||||
- exactly 10 allies, 12 ordinary enemies and one boss in every frame;
|
||||
- contracted actor/boss heights and battle-safe bounds;
|
||||
- exactly two allied and two enemy-side high-salience assignments per frame;
|
||||
- H.264 2048x1152, 8 FPS, 16 frames and 2.0 seconds;
|
||||
- background emptiness, faction separation and boss distinction.
|
||||
|
||||
This is a product-proof failure, not a sprite-source failure or review harness
|
||||
invalidity. The original candidate remains append-only. Repair is limited to
|
||||
manifest-aware non-loop scheduling and fresh derived evidence.
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user