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>
68 lines
2.3 KiB
Python
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()
|