#!/usr/bin/env python3 """Generate placeholder stick-figure characters (frames + frame data). These stand in until MODELBEAST-rendered characters come through the pipeline; the folder layout they produce IS the production format (characters/_spec/CHARACTER_SPEC.md). Usage: python3 tools/gen_placeholder_char.py alpha '#3ec6a8' python3 tools/gen_placeholder_char.py beta '#e06040' """ import json import math import sys from pathlib import Path from PIL import Image, ImageDraw W, H = 256, 352 GROUND = 340 # ground pivot y inside the canvas ART_FPS_DIV = 3 # 1 art frame per 3 ticks (20fps art on 60Hz logic) # skeleton lengths (px) TORSO, HEAD_R = 95, 26 UP_ARM, FORE_ARM = 52, 48 THIGH, SHIN = 78, 80 PELVIS_H = 150 # pelvis height above ground when standing # angle convention: 0 = straight down, positive = toward +x (forward) BASE = dict(lean=4, pelvis_y=PELVIS_H, shR=28, elR=105, shL=-14, elL=95, # guard arms hipR=13, knR=-14, hipL=-13, knL=10) # boxer stance def P(**kw): p = dict(BASE) p.update(kw) return p def lerp_pose(a, b, t): return {k: a[k] + (b[k] - a[k]) * t for k in a} def pose_at(keys, frac): """keys: [(frac, pose), ...] sorted; linear interpolation between them.""" if frac <= keys[0][0]: return keys[0][1] for i in range(len(keys) - 1): f0, p0 = keys[i] f1, p1 = keys[i + 1] if frac <= f1: return lerp_pose(p0, p1, (frac - f0) / (f1 - f0) if f1 > f0 else 0) return keys[-1][1] def limb(origin, angle_deg, length): a = math.radians(angle_deg) return (origin[0] + length * math.sin(a), origin[1] + length * math.cos(a)) def draw_pose(p, color): img = Image.new("RGBA", (W, H), (0, 0, 0, 0)) d = ImageDraw.Draw(img) ox = W / 2 pelvis = (ox + p.get("shift_x", 0), GROUND - p["pelvis_y"]) neck = limb(pelvis, 180 + p["lean"], TORSO) # torso points up (180=up) head_c = limb(neck, 180 + p["lean"], HEAD_R + 12) def stroke(a, b, w=15): d.line([a, b], fill=color, width=w) for pt in (a, b): d.ellipse([pt[0] - w / 2, pt[1] - w / 2, pt[0] + w / 2, pt[1] + w / 2], fill=color) # legs (draw left first so right/front overlaps) for hip_k, knee_k, w in (("hipL", "knL", 15), ("hipR", "knR", 16)): knee = limb(pelvis, p[hip_k], THIGH) foot = limb(knee, p[hip_k] + p[knee_k], SHIN) stroke(pelvis, knee, w) stroke(knee, foot, w) # torso stroke(pelvis, neck, 22) # arms for sh_k, el_k, w in (("shL", "elL", 12), ("shR", "elR", 13)): elbow = limb(neck, p[sh_k], UP_ARM) hand = limb(elbow, p[sh_k] + p[el_k], FORE_ARM) stroke(neck, elbow, w) stroke(elbow, hand, w) # head d.ellipse([head_c[0] - HEAD_R, head_c[1] - HEAD_R, head_c[0] + HEAD_R, head_c[1] + HEAD_R], fill=color) return img # ---------------------------------------------------------------- poses GUARD = P() JAB_EXT = P(lean=10, shR=92, elR=-4, shL=-20, elL=100) STRAIGHT_EXT = P(lean=15, shL=94, elL=-2, shR=18, elR=110, hipR=20, hipL=-22) KICK_CHAMBER = P(lean=-8, hipR=55, knR=-95) KICK_EXT = P(lean=-14, hipR=88, knR=-4, hipL=-16) PALM_EXT = P(lean=16, shL=90, elL=0, shR=40, elR=120, hipR=32, hipL=-30, pelvis_y=PELVIS_H - 12) CROUCH = P(pelvis_y=78, hipR=58, knR=-116, hipL=-52, knL=110, lean=12) JUMP_TUCK = P(hipR=42, knR=-100, hipL=-38, knL=95) BLOCK = P(shR=44, elR=118, shL=30, elL=125, lean=2) HIT_REEL = P(lean=-16, shR=48, elR=60, shL=-30, elL=50) FALL_BACK = P(lean=-55, pelvis_y=100, hipR=35, knR=-40, hipL=15, knL=-20, shR=70, elR=30, shL=-60, elL=20) LYING = P(lean=-92, pelvis_y=26, hipR=-80, knR=-8, hipL=-86, knL=4, shR=100, elR=6, shL=-95, elL=4) RISE = P(lean=-30, pelvis_y=95, hipR=48, knR=-95, hipL=-40, knL=85) def walk_keys(back=False): s = -1 if back else 1 a = P(hipR=24 * s, hipL=-20 * s, knR=-18, knL=14) b = P(hipR=-20 * s, hipL=24 * s, knR=-14, knL=18) return [(0.0, a), (0.5, b), (1.0, a)] IDLE_A = P() IDLE_B = P(pelvis_y=PELVIS_H - 5, shR=32, shL=-18) # move library: name -> (keyframes, data.json fields) MOVES = { "idle": ([(0.0, IDLE_A), (0.5, IDLE_B), (1.0, IDLE_A)], dict(total=48, loop=True)), "walk_f": (walk_keys(), dict(total=40, loop=True)), "walk_b": (walk_keys(back=True), dict(total=40, loop=True)), "crouch": ([(0.0, GUARD), (0.6, CROUCH), (1.0, CROUCH)], dict(total=12)), "jump": ([(0.0, GUARD), (0.3, JUMP_TUCK), (0.8, JUMP_TUCK), (1.0, GUARD)], dict(total=48)), "block": ([(0.0, BLOCK), (1.0, BLOCK)], dict(total=12)), "hit": ([(0.0, GUARD), (0.3, HIT_REEL), (1.0, GUARD)], dict(total=16)), "knockdown": ([(0.0, HIT_REEL), (0.4, FALL_BACK), (0.75, LYING), (1.0, LYING)], dict(total=42)), "getup": ([(0.0, LYING), (0.5, RISE), (1.0, GUARD)], dict(total=26)), "jab": ([(0.0, GUARD), (0.23, JAB_EXT), (0.42, JAB_EXT), (1.0, GUARD)], dict(total=22, startup=5, active=[6, 8], damage=30, hitstun=14, blockstun=8, pushback=7, hitboxes={"6-8": [[34, -276, 76, 32]]})), "straight": ([(0.0, GUARD), (0.3, STRAIGHT_EXT), (0.5, STRAIGHT_EXT), (1.0, GUARD)], dict(total=30, startup=9, active=[10, 13], damage=55, hitstun=18, blockstun=10, pushback=11, hitboxes={"10-13": [[38, -282, 96, 36]]})), "kick": ([(0.0, GUARD), (0.25, KICK_CHAMBER), (0.42, KICK_EXT), (0.58, KICK_EXT), (0.8, KICK_CHAMBER), (1.0, GUARD)], dict(total=34, startup=11, active=[12, 16], damage=70, hitstun=20, blockstun=12, pushback=13, hitboxes={"12-16": [[30, -240, 112, 46]]})), "rush_palm": ([(0.0, GUARD), (0.2, KICK_CHAMBER), (0.38, PALM_EXT), (0.6, PALM_EXT), (1.0, GUARD)], dict(total=40, startup=13, active=[14, 18], damage=90, hitstun=24, blockstun=14, pushback=16, knockdown=True, root_motion=[0, 0, 0, 0, 4, 8, 12, 14, 14, 12, 10, 8, 6, 4, 2, 2, 1, 1], hitboxes={"14-18": [[36, -288, 94, 54]]})), } HURT_DEFAULT = [[-22, -308, 44, 52], [-30, -256, 60, 106], [-26, -150, 52, 150]] HURT_CROUCH = [[-28, -215, 56, 215]] HURT_LYING = [[-60, -60, 130, 60]] def gen_character(cid: str, color: str, name: str | None = None): root = Path(__file__).resolve().parent.parent / "characters" / cid for mname, (keys, fields) in MOVES.items(): mdir = root / "moves" / mname (mdir / "frames").mkdir(parents=True, exist_ok=True) total = fields["total"] n_frames = max(2, round(total / ART_FPS_DIV)) for i in range(n_frames): frac = i / (n_frames - 1) if not fields.get("loop") else i / n_frames img = draw_pose(pose_at(keys, frac), color) img.save(mdir / "frames" / f"{i:04d}.png") data = dict(name=mname, **fields) if mname == "crouch": data["hurtboxes"] = {"default": HURT_CROUCH} elif mname in ("knockdown", "getup"): data["hurtboxes"] = {"default": HURT_LYING} else: data["hurtboxes"] = {"default": HURT_DEFAULT} (mdir / "data.json").write_text(json.dumps(data, indent=2)) manifest = { "id": cid, "name": (name or cid).upper(), "health": 1000, "walk_speed": 3.2, "back_speed": 2.6, "jump_velocity": -13.5, "color": color, "normals": {"P": "jab", "6P": "straight", "K": "kick"}, "command_moves": [{"motion": [2, 3, 6], "button": "P", "move": "rush_palm"}], } (root / "manifest.json").write_text(json.dumps(manifest, indent=2)) # portrait: head crop of idle frame 0 idle = draw_pose(IDLE_A, color) portrait = idle.crop((W // 2 - 60, 20, W // 2 + 60, 140)).resize((120, 120)) bg = Image.new("RGBA", (120, 120), (24, 26, 32, 255)) bg.alpha_composite(portrait) bg.save(root / "portrait.png") print(f"generated characters/{cid}: {len(MOVES)} moves") if __name__ == "__main__": cid = sys.argv[1] if len(sys.argv) > 1 else "alpha" color = sys.argv[2] if len(sys.argv) > 2 else "#3ec6a8" gen_character(cid, color)