foitin/tools/tune_finishers.py
m3ultra c3d682b7dd Finisher timing pass: measured impact windows for all batch fighters
tools/tune_finishers.py detects each finisher's impact frame (max forward
extent of the sprite) and sets real-time totals + active windows; values
persist in finisher_timings.json and win over DEFAULT_FINISHER in tune.
kachujin/vesper keep hand-tuned values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:31:13 +10:00

68 lines
2.3 KiB
Python

#!/usr/bin/env python3
"""Per-fighter finisher timing from the actual frames.
The impact moment of a strike is the frame of maximum forward extent
(rightmost opaque pixel, fighters render facing right). Sets:
total = frames * 6 ticks (batch renders sample at 10fps -> real-time)
active = impact frame .. +3 frames, startup just before
Writes tools/finisher_timings.json (merged by tune_fighters.py) and
patches each characters/<id>/moves/finisher/data.json in place.
"""
import json
import sys
from pathlib import Path
import numpy as np
from PIL import Image
REPO = Path(__file__).resolve().parent.parent
OUT = REPO / "tools/finisher_timings.json"
def impact_frame(frames_dir: Path):
frames = sorted(frames_dir.glob("*.png"))
ext = []
for f in frames:
a = np.array(Image.open(f).split()[-1])
cols = np.where(a.max(axis=0) > 32)[0]
ext.append(cols[-1] if len(cols) else 0)
if not ext:
return None
lo = max(int(len(ext) * 0.2), 1) # skip windup extension
i = lo + int(np.argmax(ext[lo:]))
return i, len(frames)
def main() -> None:
chars = sys.argv[1:] or [d.name for d in (REPO / "characters").iterdir()
if not d.name.startswith("_")]
timings = json.loads(OUT.read_text()) if OUT.exists() else {}
for cid in sorted(chars):
fdir = REPO / "characters" / cid / "moves/finisher/frames"
if not fdir.exists():
continue
res = impact_frame(fdir)
if res is None:
continue
i, n = res
tpf = 6 # ticks per art frame
total = n * tpf
a0, a1 = i * tpf, min((i + 4) * tpf - 1, total - 2)
t = dict(total=total, startup=max(a0 - 1, 1), active=[a0, a1], damage=0,
finisher=True, hitstun=0, blockstun=0, pushback=0,
hitboxes={f"{a0}-{a1}": [[0, -320, 180, 300]]})
timings[cid] = t
data_path = fdir.parent / "data.json"
data = json.loads(data_path.read_text())
hurt = data.get("hurtboxes")
data.update(t)
if hurt:
data["hurtboxes"] = hurt
data_path.write_text(json.dumps(data, indent=2))
print(f"{cid}: impact frame {i}/{n} -> active ticks {a0}-{a1}, total {total}")
OUT.write_text(json.dumps(timings, indent=2))
if __name__ == "__main__":
main()