foitin/pipeline/pack_character.py
m3ultra da1cceadc2 Two real fighters: KACHUJIN and VESPER, 11 moves each from Mixamo combat clips
- 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>
2026-07-28 19:50:56 +10:00

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()