foitin/pipeline/pack_character.py
m3ultra 54a54f2467 Torn-clothing wardrobe mechanic (Phase A)
- engine: wardrobe states per character (manifest wardrobe.states),
  frames@<state> variant frame-sets per move with base fallback,
  tears:true moves advance state, hp threshold forces final state
- pipeline: --torn punches jagged alpha holes into clothing textures
  (alpha-clip materials) and renders @torn variants; pack maps them
- both fighters: full torn frame-sets for all 12 moves
- special gets designed forward lunge (clip travel was backward)
- verified: demo QCF+P connects, knockdown shows torn frames

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 21:09:06 +10:00

88 lines
3.5 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
# "<move>@<state>" renders are wardrobe variants: frames go to
# moves/<move>/frames@<state>/ and no data.json is touched
if "@" in move_dir.name:
move, state = move_dir.name.split("@", 1)
vout = dst / "moves" / move / ("frames@" + state)
if vout.exists():
shutil.rmtree(vout)
vout.mkdir(parents=True, exist_ok=True)
for i, f in enumerate(frames):
shutil.copy(f, vout / f"{i:04d}{f.suffix}")
moves.append(move_dir.name)
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()