- per-frame hips-centred rendering + root-motion export (the BKB .geo approach modernised): traveling clips stay pivot-stable, movement data goes to root_motion_frames in data.json - engine: frame-indexed root motion, pushbox widened for real sprites - clip set: ginga idle, walks, jump, block, punching/boxing/martelo normals, chapa-giratoria QCF special, hit react, knockdown - verified in-game: demo jab connects, damage applied, mirroring correct Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
76 lines
2.9 KiB
Python
76 lines
2.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Pack rendered frames into a character folder (CHARACTER_SPEC.md).
|
|
|
|
Takes a renders directory laid out as renders/<char>/<move>/*.png and
|
|
assembles characters/<char>/ with a data.json template per move (timing
|
|
fields to be tuned by hand, hurtboxes to be filled by autohitbox.py).
|
|
|
|
Usage:
|
|
python3 pipeline/pack_character.py renders/hero --name HERO --color '#cc4488'
|
|
"""
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
|
|
DATA_TEMPLATE = dict(total=30, loop=False, startup=10, active=[11, 14],
|
|
damage=50, hitstun=16, blockstun=10, pushback=10,
|
|
hurtboxes={"default": [[-25, -300, 50, 300]]}, hitboxes={})
|
|
LOOPED = {"idle", "walk_f", "walk_b"}
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("renders_dir", help="renders/<char> containing <move>/*.png")
|
|
ap.add_argument("--name", default=None)
|
|
ap.add_argument("--color", default="#cccccc")
|
|
ap.add_argument("--health", type=int, default=1000)
|
|
args = ap.parse_args()
|
|
|
|
src = Path(args.renders_dir)
|
|
cid = src.name
|
|
dst = REPO / "characters" / cid
|
|
|
|
moves = []
|
|
for move_dir in sorted(p for p in src.iterdir() if p.is_dir()):
|
|
frames = sorted(move_dir.glob("*.png")) + sorted(move_dir.glob("*.webp"))
|
|
if not frames:
|
|
continue
|
|
out = dst / "moves" / move_dir.name / "frames"
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
for i, f in enumerate(frames):
|
|
shutil.copy(f, out / f"{i:04d}{f.suffix}")
|
|
rm_path = move_dir / "root_motion.json"
|
|
root_frames = json.loads(rm_path.read_text()) if rm_path.exists() else None
|
|
data_path = out.parent / "data.json"
|
|
if not data_path.exists(): # never clobber hand-tuned frame data
|
|
data = dict(name=move_dir.name, **DATA_TEMPLATE)
|
|
data["loop"] = move_dir.name in LOOPED
|
|
if root_frames:
|
|
data["root_motion_frames"] = root_frames
|
|
data_path.write_text(json.dumps(data, indent=2))
|
|
elif root_frames is not None: # refresh clip-derived motion on repack
|
|
data = json.loads(data_path.read_text())
|
|
data["root_motion_frames"] = root_frames
|
|
data_path.write_text(json.dumps(data, indent=2))
|
|
moves.append(move_dir.name)
|
|
|
|
manifest_path = dst / "manifest.json"
|
|
if not manifest_path.exists():
|
|
manifest_path.write_text(json.dumps({
|
|
"id": cid, "name": (args.name or cid).upper(),
|
|
"health": args.health, "walk_speed": 3.2, "back_speed": 2.6,
|
|
"jump_velocity": -13.5, "color": args.color,
|
|
"normals": {"P": "jab", "K": "kick"},
|
|
"command_moves": [],
|
|
}, indent=2))
|
|
|
|
print(f"packed characters/{cid}: {len(moves)} moves -> {moves}")
|
|
print("next: pipeline/autohitbox.py for hurtboxes, then tune data.json timings")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|