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>
This commit is contained in:
m3ultra 2026-07-29 14:31:13 +10:00
parent e624bec379
commit c3d682b7dd
17 changed files with 480 additions and 84 deletions

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 198,
"loop": false,
"startup": 78,
"startup": 95,
"active": [
82,
96
96,
119
],
"damage": 0,
"hitstun": 0,
@ -676,11 +676,11 @@
}
},
"hitboxes": {
"82-96": [
"96-119": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 138,
"loop": false,
"startup": 78,
"startup": 23,
"active": [
82,
96
24,
47
],
"damage": 0,
"hitstun": 0,
@ -476,11 +476,11 @@
}
},
"hitboxes": {
"82-96": [
"24-47": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 132,
"loop": false,
"startup": 78,
"startup": 65,
"active": [
82,
96
66,
89
],
"damage": 0,
"hitstun": 0,
@ -456,11 +456,11 @@
}
},
"hitboxes": {
"82-96": [
"66-89": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 204,
"loop": false,
"startup": 78,
"startup": 131,
"active": [
82,
96
132,
155
],
"damage": 0,
"hitstun": 0,
@ -696,11 +696,11 @@
}
},
"hitboxes": {
"82-96": [
"132-155": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 300,
"loop": false,
"startup": 78,
"startup": 77,
"active": [
82,
96
78,
101
],
"damage": 0,
"hitstun": 0,
@ -636,11 +636,11 @@
}
},
"hitboxes": {
"82-96": [
"78-101": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 144,
"loop": false,
"startup": 78,
"startup": 65,
"active": [
82,
96
66,
89
],
"damage": 0,
"hitstun": 0,
@ -496,11 +496,11 @@
}
},
"hitboxes": {
"82-96": [
"66-89": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 126,
"loop": false,
"startup": 78,
"startup": 89,
"active": [
82,
96
90,
113
],
"damage": 0,
"hitstun": 0,
@ -436,11 +436,11 @@
}
},
"hitboxes": {
"82-96": [
"90-113": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 186,
"loop": false,
"startup": 78,
"startup": 77,
"active": [
82,
96
78,
101
],
"damage": 0,
"hitstun": 0,
@ -616,11 +616,11 @@
}
},
"hitboxes": {
"82-96": [
"78-101": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 258,
"loop": false,
"startup": 78,
"startup": 197,
"active": [
82,
96
198,
221
],
"damage": 0,
"hitstun": 0,
@ -876,11 +876,11 @@
}
},
"hitboxes": {
"82-96": [
"198-221": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 258,
"loop": false,
"startup": 78,
"startup": 197,
"active": [
82,
96
198,
221
],
"damage": 0,
"hitstun": 0,
@ -616,11 +616,11 @@
}
},
"hitboxes": {
"82-96": [
"198-221": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 324,
"loop": false,
"startup": 78,
"startup": 287,
"active": [
82,
96
288,
311
],
"damage": 0,
"hitstun": 0,
@ -1096,11 +1096,11 @@
}
},
"hitboxes": {
"82-96": [
"288-311": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 150,
"loop": false,
"startup": 78,
"startup": 35,
"active": [
82,
96
36,
59
],
"damage": 0,
"hitstun": 0,
@ -516,11 +516,11 @@
}
},
"hitboxes": {
"82-96": [
"36-59": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 126,
"loop": false,
"startup": 78,
"startup": 53,
"active": [
82,
96
54,
77
],
"damage": 0,
"hitstun": 0,
@ -436,11 +436,11 @@
}
},
"hitboxes": {
"82-96": [
"54-77": [
[
0,
-320,
170,
180,
300
]
]

View File

@ -1,11 +1,11 @@
{
"name": "finisher",
"total": 220,
"total": 324,
"loop": false,
"startup": 78,
"startup": 179,
"active": [
82,
96
180,
203
],
"damage": 0,
"hitstun": 0,
@ -1096,11 +1096,11 @@
}
},
"hitboxes": {
"82-96": [
"180-203": [
[
0,
-320,
170,
180,
300
]
]

324
tools/finisher_timings.json Normal file
View File

@ -0,0 +1,324 @@
{
"birdie": {
"total": 198,
"startup": 95,
"active": [
96,
119
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"96-119": [
[
0,
-320,
180,
300
]
]
}
},
"chef": {
"total": 138,
"startup": 23,
"active": [
24,
47
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"24-47": [
[
0,
-320,
180,
300
]
]
}
},
"decks": {
"total": 132,
"startup": 65,
"active": [
66,
89
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"66-89": [
[
0,
-320,
180,
300
]
]
}
},
"dread": {
"total": 204,
"startup": 131,
"active": [
132,
155
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"132-155": [
[
0,
-320,
180,
300
]
]
}
},
"elva": {
"total": 300,
"startup": 77,
"active": [
78,
101
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"78-101": [
[
0,
-320,
180,
300
]
]
}
},
"exec": {
"total": 144,
"startup": 65,
"active": [
66,
89
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"66-89": [
[
0,
-320,
180,
300
]
]
}
},
"jima": {
"total": 126,
"startup": 89,
"active": [
90,
113
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"90-113": [
[
0,
-320,
180,
300
]
]
}
},
"jinx": {
"total": 186,
"startup": 77,
"active": [
78,
101
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"78-101": [
[
0,
-320,
180,
300
]
]
}
},
"nappo": {
"total": 258,
"startup": 197,
"active": [
198,
221
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"198-221": [
[
0,
-320,
180,
300
]
]
}
},
"raver": {
"total": 258,
"startup": 197,
"active": [
198,
221
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"198-221": [
[
0,
-320,
180,
300
]
]
}
},
"roca": {
"total": 324,
"startup": 287,
"active": [
288,
311
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"288-311": [
[
0,
-320,
180,
300
]
]
}
},
"umbra": {
"total": 150,
"startup": 35,
"active": [
36,
59
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"36-59": [
[
0,
-320,
180,
300
]
]
}
},
"violet": {
"total": 126,
"startup": 53,
"active": [
54,
77
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"54-77": [
[
0,
-320,
180,
300
]
]
}
},
"widow": {
"total": 324,
"startup": 179,
"active": [
180,
203
],
"damage": 0,
"finisher": true,
"hitstun": 0,
"blockstun": 0,
"pushback": 0,
"hitboxes": {
"180-203": [
[
0,
-320,
180,
300
]
]
}
}
}

View File

@ -130,6 +130,9 @@ MANIFESTS = {
def main() -> None:
chars = sys.argv[1:] or list(MANIFESTS)
# measured impact windows (tools/tune_finishers.py) beat DEFAULT_FINISHER
timings_path = REPO / "tools/finisher_timings.json"
timings = json.loads(timings_path.read_text()) if timings_path.exists() else {}
for cid in chars:
root = REPO / "characters" / cid
if not root.exists():
@ -137,6 +140,8 @@ def main() -> None:
continue
merged = dict(TUNE)
merged.update(CHAR_TUNE.get(cid, {}))
if cid in timings:
merged["finisher"] = timings[cid]
for move, overrides in merged.items():
data_path = root / "moves" / move / "data.json"
if not data_path.exists():

67
tools/tune_finishers.py Normal file
View File

@ -0,0 +1,67 @@
#!/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()