Roster batch tooling: 6 new fighters from the asset library
- pipeline/batch_roster.py: model + clip-kit per fighter, renders base+torn, packs, hitboxes, tunes, portraits — sequential batch - --strip flag (mesh removal) added; Mixamo pack models turn out hollow under clothes, so this batch ships base+torn; true clothes-off waits on rigging the wardrobegod nude bases (phrtt2, daphne) - tune_fighters: manifests + default finisher timing for raver/chef/ exec/jinx/elva/umbra; getup move tuned - select screen wraps to a 4-per-row grid Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d743216b54
commit
0f83637fcc
@ -42,11 +42,13 @@ func _ready() -> void:
|
|||||||
entry.portrait = ImageTexture.create_from_image(img)
|
entry.portrait = ImageTexture.create_from_image(img)
|
||||||
roster.append(entry)
|
roster.append(entry)
|
||||||
|
|
||||||
var total_w: int = roster.size() * (CARD_W + 40) - 40
|
var per_row: int = mini(roster.size(), 4)
|
||||||
|
var total_w: int = per_row * (CARD_W + 40) - 40
|
||||||
var x0: float = 640.0 - total_w / 2.0
|
var x0: float = 640.0 - total_w / 2.0
|
||||||
for i in roster.size():
|
for i in roster.size():
|
||||||
var card := Node2D.new()
|
var card := Node2D.new()
|
||||||
card.position = Vector2(x0 + i * (CARD_W + 40), 220)
|
card.position = Vector2(x0 + (i % per_row) * (CARD_W + 40),
|
||||||
|
170 + int(i / float(per_row)) * (CARD_H + 60) as float)
|
||||||
add_child(card)
|
add_child(card)
|
||||||
var frame := ColorRect.new()
|
var frame := ColorRect.new()
|
||||||
frame.color = Color("#22252e")
|
frame.color = Color("#22252e")
|
||||||
|
|||||||
110
pipeline/batch_roster.py
Normal file
110
pipeline/batch_roster.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Batch-build roster fighters: render (base + torn) -> pack -> hitbox -> tune.
|
||||||
|
|
||||||
|
Each fighter = model + clip kit. Runs sequentially; a fighter appears on the
|
||||||
|
select screen as soon as its pack+tune completes. Heartbeat: ~/.jobs/foitin-roster.hb
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
BLENDER = "/Applications/Blender.app/Contents/MacOS/Blender"
|
||||||
|
M = Path.home() / "Documents/mixamo-animations"
|
||||||
|
R = Path.home() / "Documents/foitin_assets/roster"
|
||||||
|
HB = Path.home() / ".jobs/foitin-roster.hb"
|
||||||
|
HB.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
SHARED = {
|
||||||
|
"walk_f": "Walk Forward Left", "walk_b": "Stepping Backward",
|
||||||
|
"jump": "Jump Up", "block": "Standing Block", "crouch": "Standing To Crouch",
|
||||||
|
"hit": "Taking Punch", "knockdown": "Death From Right", "dizzy": "Stunned",
|
||||||
|
"death": "Standing Death Backward 01", "getup": "Crouch To Stand",
|
||||||
|
}
|
||||||
|
|
||||||
|
FIGHTERS = {
|
||||||
|
"raver": dict(model="woman_raver_01.fbx", torn="Ch03",
|
||||||
|
kit={"idle": "Ready Idle", "jab": "Elbow Punch",
|
||||||
|
"straight": "Cross Punch", "kick": "Kicking (1)",
|
||||||
|
"special": "Spin Flip Kick", "finisher": "Grab And Slam"}),
|
||||||
|
"chef": dict(model="woman_chef_01.fbx", torn="Shirt,Pants",
|
||||||
|
kit={"idle": "Bouncing Fight Idle", "jab": "Punching (1)",
|
||||||
|
"straight": "Hook Punch", "kick": "Standing Melee Kick",
|
||||||
|
"special": "Drop Kick", "finisher": "Standing Melee Attack Downward"}),
|
||||||
|
"exec": dict(model="woman_business_01.fbx", torn="Suit,Pants",
|
||||||
|
kit={"idle": "Ready Idle", "jab": "Punching",
|
||||||
|
"straight": "Hook Punch", "kick": "Roundhouse Kick",
|
||||||
|
"special": "Flying Kick", "finisher": "Stabbing"}),
|
||||||
|
"jinx": dict(model="woman_casual_02.fbx", torn="Shirt,Pants",
|
||||||
|
kit={"idle": "Bouncing Fight Idle", "jab": "Boxing (1)",
|
||||||
|
"straight": "Boxing (2)", "kick": "Kicking (3)",
|
||||||
|
"special": "Flying Knee Punch Combo", "finisher": "Double Dagger Stab"}),
|
||||||
|
"elva": dict(model="monster_elf_01.fbx", torn="Ch34",
|
||||||
|
kit={"idle": "Ready Idle", "jab": "Elbow Punching",
|
||||||
|
"straight": "Cross Punch", "kick": "Kicking (4)",
|
||||||
|
"special": "Butterfly Twirl", "finisher": "Brutal Assassination (1)"}),
|
||||||
|
"umbra": dict(model="monster_shadow_f_01.fbx", torn="Ch47",
|
||||||
|
kit={"idle": "Bouncing Fight Idle", "jab": "Punching (1)",
|
||||||
|
"straight": "Hook Punch", "kick": "Kicking (5)",
|
||||||
|
"special": "Armada", "finisher": "Standing Melee Attack Horizontal"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hb(msg: str) -> None:
|
||||||
|
HB.write_text(f"{time.strftime('%H:%M:%S')} {msg}\n")
|
||||||
|
print(msg, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def render(char: Path, clips: str, out: Path, torn: str | None) -> None:
|
||||||
|
cmd = [BLENDER, "-b", "--python", str(REPO / "pipeline/render_moves.py"), "--",
|
||||||
|
"--char", str(char), "--clips", clips, "--out", str(out),
|
||||||
|
"--step", "3", "--rotz", "155", "--max-frames", "160"]
|
||||||
|
if torn:
|
||||||
|
cmd += ["--torn", torn]
|
||||||
|
subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def build(cid: str, cfg: dict) -> None:
|
||||||
|
moves = dict(SHARED)
|
||||||
|
moves.update(cfg["kit"])
|
||||||
|
clips = ",".join(str(M / f"{c}.fbx") for c in moves.values())
|
||||||
|
out = REPO / "renders" / cid
|
||||||
|
if out.exists():
|
||||||
|
shutil.rmtree(out)
|
||||||
|
|
||||||
|
hb(f"{cid}: render base ({len(moves)} clips)")
|
||||||
|
render(R / cfg["model"], clips, out, None)
|
||||||
|
hb(f"{cid}: render torn")
|
||||||
|
render(R / cfg["model"], clips, out, cfg["torn"])
|
||||||
|
|
||||||
|
# clip-name dirs -> move-name dirs (both states)
|
||||||
|
stem_to_move = {c.lower(): m for m, c in moves.items()}
|
||||||
|
for d in sorted(os.listdir(out)):
|
||||||
|
base, _, state = d.partition("@")
|
||||||
|
if base in stem_to_move:
|
||||||
|
new = stem_to_move[base] + (("@" + state) if state else "")
|
||||||
|
os.rename(out / d, out / new)
|
||||||
|
|
||||||
|
hb(f"{cid}: pack + hitbox + tune")
|
||||||
|
subprocess.run([str(REPO / ".venv/bin/python"), str(REPO / "pipeline/pack_character.py"),
|
||||||
|
str(out)], capture_output=True, check=False)
|
||||||
|
subprocess.run([str(REPO / ".venv/bin/python"), str(REPO / "pipeline/autohitbox.py"),
|
||||||
|
str(REPO / "characters" / cid)], capture_output=True, check=False)
|
||||||
|
subprocess.run([str(REPO / ".venv/bin/python"), str(REPO / "tools/tune_fighters.py"),
|
||||||
|
cid], capture_output=True, check=False)
|
||||||
|
|
||||||
|
# portrait from idle frame 0 (alpha-bbox head crop)
|
||||||
|
subprocess.run([str(REPO / ".venv/bin/python"), str(REPO / "tools/make_portrait.py"),
|
||||||
|
cid], capture_output=True, check=False)
|
||||||
|
hb(f"{cid}: DONE")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
targets = sys.argv[1:] or list(FIGHTERS)
|
||||||
|
for cid in targets:
|
||||||
|
build(cid, FIGHTERS[cid])
|
||||||
|
hb("ALL DONE")
|
||||||
@ -42,6 +42,9 @@ def parse_args():
|
|||||||
help="comma list of mesh-name substrings to tear (punch alpha "
|
help="comma list of mesh-name substrings to tear (punch alpha "
|
||||||
"holes in their textures); output dirs get an @torn suffix")
|
"holes in their textures); output dirs get an @torn suffix")
|
||||||
ap.add_argument("--torn-seed", type=int, default=7)
|
ap.add_argument("--torn-seed", type=int, default=7)
|
||||||
|
ap.add_argument("--strip", default="",
|
||||||
|
help="comma list of mesh-name substrings to REMOVE (clothes "
|
||||||
|
"knocked off); output dirs get an @stripped suffix")
|
||||||
ap.add_argument("--attach", default="",
|
ap.add_argument("--attach", default="",
|
||||||
help="gear to bone-attach, ';'-separated entries of "
|
help="gear to bone-attach, ';'-separated entries of "
|
||||||
"glb|Bone|scale|dx,dy,dz|rx,ry,rz (offsets in bone space, "
|
"glb|Bone|scale|dx,dy,dz|rx,ry,rz (offsets in bone space, "
|
||||||
@ -286,6 +289,14 @@ def main():
|
|||||||
if args.torn:
|
if args.torn:
|
||||||
tear_clothing(objs, args.torn, args.torn_seed)
|
tear_clothing(objs, args.torn, args.torn_seed)
|
||||||
variant_suffix = "@torn"
|
variant_suffix = "@torn"
|
||||||
|
if args.strip:
|
||||||
|
keys = [k.strip().lower() for k in args.strip.split(",") if k.strip()]
|
||||||
|
for o in list(objs):
|
||||||
|
if o.type == "MESH" and any(k in o.name.lower() for k in keys):
|
||||||
|
print("STRIPPED", o.name)
|
||||||
|
objs.remove(o)
|
||||||
|
bpy.data.objects.remove(o, do_unlink=True)
|
||||||
|
variant_suffix = "@stripped"
|
||||||
if args.attach:
|
if args.attach:
|
||||||
attach_gear(arm, args.attach)
|
attach_gear(arm, args.attach)
|
||||||
variant_suffix = "@geared"
|
variant_suffix = "@geared"
|
||||||
|
|||||||
27
tools/make_portrait.py
Normal file
27
tools/make_portrait.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Select-screen portrait: alpha-bbox head/torso crop of idle frame 0."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
for cid in sys.argv[1:]:
|
||||||
|
src = REPO / "characters" / cid / "moves/idle/frames/0000.png"
|
||||||
|
if not src.exists():
|
||||||
|
print(f"skip {cid}: no idle frame")
|
||||||
|
continue
|
||||||
|
img = Image.open(src)
|
||||||
|
a = np.array(img.split()[-1])
|
||||||
|
rows = np.where(a.max(axis=1) > 32)[0]
|
||||||
|
cols = np.where(a.max(axis=0) > 32)[0]
|
||||||
|
top, bot, l, r = rows[0], rows[-1], cols[0], cols[-1]
|
||||||
|
h = int((bot - top) * 0.45)
|
||||||
|
cx = (l + r) // 2
|
||||||
|
crop = img.crop((max(cx - h // 2, 0), top, min(cx + h // 2, img.width), top + h))
|
||||||
|
bg = Image.new("RGBA", (160, 160), (24, 26, 32, 255))
|
||||||
|
bg.alpha_composite(crop.resize((160, 160), Image.LANCZOS))
|
||||||
|
bg.save(REPO / "characters" / cid / "portrait.png")
|
||||||
|
print(f"portrait {cid}")
|
||||||
@ -25,6 +25,7 @@ TUNE = {
|
|||||||
"lose": dict(total=110, root_motion_frames=[]),
|
"lose": dict(total=110, root_motion_frames=[]),
|
||||||
"dizzy": dict(total=90, loop=True, root_motion_frames=[]),
|
"dizzy": dict(total=90, loop=True, root_motion_frames=[]),
|
||||||
"death": dict(total=150, root_motion_frames=[]),
|
"death": dict(total=150, root_motion_frames=[]),
|
||||||
|
"getup": dict(total=30, root_motion_frames=[]),
|
||||||
"block": dict(total=24, root_motion_frames=[]),
|
"block": dict(total=24, root_motion_frames=[]),
|
||||||
"hit": dict(total=24, root_motion_frames=[]),
|
"hit": dict(total=24, root_motion_frames=[]),
|
||||||
"knockdown": dict(total=60, hurtboxes={"default": [[-70, -70, 150, 70]]}),
|
"knockdown": dict(total=60, hurtboxes={"default": [[-70, -70, 150, 70]]}),
|
||||||
@ -46,6 +47,11 @@ TUNE = {
|
|||||||
hitboxes={"16-24": [[35, -330, 115, 80]]}),
|
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
|
# per-character move overrides merged after the global TUNE
|
||||||
CHAR_TUNE = {
|
CHAR_TUNE = {
|
||||||
"kachujin": {
|
"kachujin": {
|
||||||
@ -58,8 +64,26 @@ CHAR_TUNE = {
|
|||||||
finisher=True, hitstun=0, blockstun=0, pushback=0,
|
finisher=True, hitstun=0, blockstun=0, pushback=0,
|
||||||
hitboxes={"80-94": [[0, -320, 170, 300]]}),
|
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},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 = {
|
MANIFESTS = {
|
||||||
"kachujin": {
|
"kachujin": {
|
||||||
"id": "kachujin", "name": "KACHUJIN", "health": 1000,
|
"id": "kachujin", "name": "KACHUJIN", "health": 1000,
|
||||||
@ -79,6 +103,12 @@ MANIFESTS = {
|
|||||||
"command_moves": [{"motion": [2, 3, 6], "button": "P", "move": "special"}],
|
"command_moves": [{"motion": [2, 3, 6], "button": "P", "move": "special"}],
|
||||||
"finisher": {"motion": [2, 3, 6], "button": "K", "move": "finisher"},
|
"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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user