foitin/tools/gen_placeholder_char.py
m3ultra b067bf7f0b Scaffold: deterministic 60Hz combat core, drop-in character format, MODELBEAST pipeline
- Godot 4.7 project; fixed-tick FSM fighter, facing-relative input buffer
  with numpad motion parsing, box collision, training-mode hitbox overlay
- characters/ = self-describing drop-in folders (spec in _spec/); two
  generated placeholder fighters (alpha, beta)
- pipeline/: pack_character.py + autohitbox.py (pose/silhouette hurtboxes)
- STUDY.md: reverse-engineering study of the BKB reference games

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:45:44 +10:00

205 lines
8.1 KiB
Python

#!/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)