diff --git a/engine/scenes/select.gd b/engine/scenes/select.gd index a8ba501b..e7dcd885 100644 --- a/engine/scenes/select.gd +++ b/engine/scenes/select.gd @@ -42,11 +42,13 @@ func _ready() -> void: entry.portrait = ImageTexture.create_from_image(img) 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 for i in roster.size(): 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) var frame := ColorRect.new() frame.color = Color("#22252e") diff --git a/pipeline/batch_roster.py b/pipeline/batch_roster.py new file mode 100644 index 00000000..b3cb4ec1 --- /dev/null +++ b/pipeline/batch_roster.py @@ -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") diff --git a/pipeline/render_moves.py b/pipeline/render_moves.py index 3bd768bc..2d6d4c31 100644 --- a/pipeline/render_moves.py +++ b/pipeline/render_moves.py @@ -42,6 +42,9 @@ def parse_args(): help="comma list of mesh-name substrings to tear (punch alpha " "holes in their textures); output dirs get an @torn suffix") 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="", help="gear to bone-attach, ';'-separated entries of " "glb|Bone|scale|dx,dy,dz|rx,ry,rz (offsets in bone space, " @@ -286,6 +289,14 @@ def main(): if args.torn: tear_clothing(objs, args.torn, args.torn_seed) 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: attach_gear(arm, args.attach) variant_suffix = "@geared" diff --git a/tools/make_portrait.py b/tools/make_portrait.py new file mode 100644 index 00000000..f886643e --- /dev/null +++ b/tools/make_portrait.py @@ -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}") diff --git a/tools/tune_fighters.py b/tools/tune_fighters.py index 17ea7efb..2fbe939f 100644 --- a/tools/tune_fighters.py +++ b/tools/tune_fighters.py @@ -25,6 +25,7 @@ TUNE = { "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]]}), @@ -46,6 +47,11 @@ TUNE = { 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": { @@ -58,8 +64,26 @@ CHAR_TUNE = { 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}, } + +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, @@ -79,6 +103,12 @@ MANIFESTS = { "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"), }