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>
162 lines
7.9 KiB
Python
162 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Apply hand-tuned frame data + manifests to the real fighters.
|
|
|
|
Run after pack_character.py. Timing values are modern fighting-game pacing
|
|
(60Hz ticks — see STUDY.md §2), NOT the source clip lengths: `total` compresses
|
|
or stretches the sprite sequence to gameplay speed. Values are per-move design
|
|
data — tweak freely and re-run.
|
|
"""
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
# move -> data.json overrides. Boxes in 512-canvas local px (origin ground-centre).
|
|
# root_motion_frames=[] strips clip travel where gameplay owns movement
|
|
# (walking speed comes from input, not animation).
|
|
TUNE = {
|
|
"idle": dict(total=96, loop=True, root_motion_frames=[]),
|
|
"walk_f": dict(total=48, loop=True, root_motion_frames=[]),
|
|
"walk_b": dict(total=56, loop=True, root_motion_frames=[]),
|
|
"jump": dict(total=48, root_motion_frames=[]),
|
|
"crouch": dict(total=20, root_motion_frames=[]),
|
|
"win": dict(total=100, root_motion_frames=[]),
|
|
"lose": dict(total=110, root_motion_frames=[]),
|
|
"dizzy": dict(total=90, loop=True, root_motion_frames=[]),
|
|
"death": dict(total=150, root_motion_frames=[]),
|
|
"getup": dict(total=30, root_motion_frames=[]),
|
|
"block": dict(total=24, root_motion_frames=[]),
|
|
"hit": dict(total=24, root_motion_frames=[]),
|
|
"knockdown": dict(total=60, hurtboxes={"default": [[-70, -70, 150, 70]]}),
|
|
"jab": dict(total=26, startup=5, active=[6, 9], damage=30, hitstun=14,
|
|
blockstun=8, pushback=8,
|
|
hitboxes={"6-9": [[40, -300, 85, 40]]}),
|
|
"straight": dict(total=34, startup=9, active=[10, 14], damage=55, hitstun=18,
|
|
blockstun=11, pushback=12,
|
|
hitboxes={"10-14": [[45, -305, 100, 45]]}),
|
|
"kick": dict(total=40, startup=12, active=[13, 18], damage=70, hitstun=22,
|
|
blockstun=13, pushback=14,
|
|
hitboxes={"13-18": [[40, -320, 120, 60]]}),
|
|
# designed lunge (per-tick root_motion beats clip-derived frames data —
|
|
# the chapa/hurricane clips travel the wrong way for gameplay)
|
|
"special": dict(total=52, startup=15, active=[16, 24], damage=95, hitstun=26,
|
|
blockstun=16, pushback=18, knockdown=True, tears=True,
|
|
root_motion=[0, 0, 0, 0, 2, 5, 8, 11, 13, 14, 14, 13, 12, 11,
|
|
10, 9, 8, 7, 6, 5, 4, 3, 2, 2, 1, 1],
|
|
hitboxes={"16-24": [[35, -330, 115, 80]]}),
|
|
}
|
|
|
|
# default finisher timing for new fighters — refine per character after seeing
|
|
DEFAULT_FINISHER = dict(total=220, startup=78, active=[82, 96], damage=0,
|
|
finisher=True, hitstun=0, blockstun=0, pushback=0,
|
|
hitboxes={"82-96": [[0, -320, 170, 300]]})
|
|
|
|
# per-character move overrides merged after the global TUNE
|
|
CHAR_TUNE = {
|
|
"kachujin": {
|
|
"finisher": dict(total=240, startup=90, active=[95, 108], damage=0,
|
|
finisher=True, hitstun=0, blockstun=0, pushback=0,
|
|
hitboxes={"95-108": [[0, -320, 170, 300]]}),
|
|
},
|
|
"vesper": {
|
|
"finisher": dict(total=210, startup=75, active=[80, 94], damage=0,
|
|
finisher=True, hitstun=0, blockstun=0, pushback=0,
|
|
hitboxes={"80-94": [[0, -320, 170, 300]]}),
|
|
},
|
|
"raver": {"finisher": DEFAULT_FINISHER},
|
|
"chef": {"finisher": DEFAULT_FINISHER},
|
|
"exec": {"finisher": DEFAULT_FINISHER},
|
|
"jinx": {"finisher": DEFAULT_FINISHER},
|
|
"elva": {"finisher": DEFAULT_FINISHER},
|
|
"umbra": {"finisher": DEFAULT_FINISHER},
|
|
"decks": {"finisher": DEFAULT_FINISHER},
|
|
"birdie": {"finisher": DEFAULT_FINISHER},
|
|
"widow": {"finisher": DEFAULT_FINISHER},
|
|
"violet": {"finisher": DEFAULT_FINISHER},
|
|
"nappo": {"finisher": DEFAULT_FINISHER},
|
|
"dread": {"finisher": DEFAULT_FINISHER},
|
|
"jima": {"finisher": DEFAULT_FINISHER},
|
|
"roca": {"finisher": DEFAULT_FINISHER},
|
|
}
|
|
|
|
|
|
def _roster_manifest(cid, name, health, walk, back, jump, color):
|
|
return {
|
|
"id": cid, "name": name, "health": health,
|
|
"walk_speed": walk, "back_speed": back, "jump_velocity": jump,
|
|
"color": color, "sprite_scale": 1.25,
|
|
"wardrobe": {"states": ["base", "torn"], "tear_below_hp": 0.35},
|
|
"normals": {"P": "jab", "6P": "straight", "K": "kick"},
|
|
"command_moves": [{"motion": [2, 3, 6], "button": "P", "move": "special"}],
|
|
"finisher": {"motion": [2, 3, 6], "button": "K", "move": "finisher"},
|
|
}
|
|
|
|
MANIFESTS = {
|
|
"kachujin": {
|
|
"id": "kachujin", "name": "KACHUJIN", "health": 1000,
|
|
"walk_speed": 3.4, "back_speed": 2.7, "jump_velocity": -13.5,
|
|
"color": "#c03a2e", "sprite_scale": 1.25,
|
|
"wardrobe": {"states": ["base", "torn"], "tear_below_hp": 0.35},
|
|
"normals": {"P": "jab", "6P": "straight", "K": "kick"},
|
|
"command_moves": [{"motion": [2, 3, 6], "button": "P", "move": "special"}],
|
|
"finisher": {"motion": [2, 3, 6], "button": "K", "move": "finisher"},
|
|
},
|
|
"vesper": {
|
|
"id": "vesper", "name": "VESPER", "health": 950,
|
|
"walk_speed": 3.7, "back_speed": 2.9, "jump_velocity": -14.0,
|
|
"color": "#b48fd0", "sprite_scale": 1.25,
|
|
"wardrobe": {"states": ["geared", "base", "torn"], "tear_below_hp": 0.35},
|
|
"normals": {"P": "jab", "6P": "straight", "K": "kick"},
|
|
"command_moves": [{"motion": [2, 3, 6], "button": "P", "move": "special"}],
|
|
"finisher": {"motion": [2, 3, 6], "button": "K", "move": "finisher"},
|
|
},
|
|
"raver": _roster_manifest("raver", "RAVER", 900, 3.9, 3.0, -14.0, "#3ec0e8"),
|
|
"chef": _roster_manifest("chef", "CHEF", 1100, 3.0, 2.4, -13.0, "#e8e0d0"),
|
|
"exec": _roster_manifest("exec", "EXEC", 950, 3.5, 2.8, -13.5, "#2e4a8a"),
|
|
"jinx": _roster_manifest("jinx", "JINX", 1000, 3.6, 2.8, -13.5, "#e05070"),
|
|
"elva": _roster_manifest("elva", "ELVA", 900, 3.8, 3.1, -14.5, "#58c060"),
|
|
"umbra": _roster_manifest("umbra", "UMBRA", 1200, 3.1, 2.5, -13.0, "#503a70"),
|
|
"decks": _roster_manifest("decks", "DECKS", 950, 3.7, 2.9, -14.0, "#8a4ae8"),
|
|
"birdie": _roster_manifest("birdie", "BIRDIE", 900, 3.8, 3.0, -14.0, "#e878b0"),
|
|
"widow": _roster_manifest("widow", "WIDOW", 1000, 3.3, 2.7, -13.2, "#383844"),
|
|
"violet": _roster_manifest("violet", "VIOLET", 950, 3.5, 2.8, -13.5, "#7a5ae0"),
|
|
"nappo": _roster_manifest("nappo", "NAPPO", 1000, 3.4, 2.7, -13.5, "#38b8a8"),
|
|
"dread": _roster_manifest("dread", "DREAD", 900, 3.9, 3.1, -14.2, "#d8a838"),
|
|
"jima": _roster_manifest("jima", "JIMA", 1050, 3.2, 2.6, -13.0, "#d8d8d8"),
|
|
"roca": _roster_manifest("roca", "ROCA", 1050, 3.3, 2.6, -13.0, "#b87848"),
|
|
}
|
|
|
|
|
|
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():
|
|
print(f"skip {cid}: not packed yet")
|
|
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():
|
|
continue
|
|
data = json.loads(data_path.read_text())
|
|
hurt = data.get("hurtboxes") # keep autohitbox output unless overridden
|
|
data.update(overrides)
|
|
if "hurtboxes" not in overrides and hurt:
|
|
data["hurtboxes"] = hurt
|
|
data_path.write_text(json.dumps(data, indent=2))
|
|
if cid in MANIFESTS:
|
|
(root / "manifest.json").write_text(json.dumps(MANIFESTS[cid], indent=2))
|
|
print(f"tuned {cid}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|