- 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>
205 lines
8.1 KiB
Python
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)
|