foitin/engine/core/move_data.gd
m3ultra da1cceadc2 Two real fighters: KACHUJIN and VESPER, 11 moves each from Mixamo combat clips
- per-frame hips-centred rendering + root-motion export (the BKB .geo
  approach modernised): traveling clips stay pivot-stable, movement data
  goes to root_motion_frames in data.json
- engine: frame-indexed root motion, pushbox widened for real sprites
- clip set: ginga idle, walks, jump, block, punching/boxing/martelo
  normals, chapa-giratoria QCF special, hit react, knockdown
- verified in-game: demo jab connects, damage applied, mirroring correct

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:50:56 +10:00

137 lines
4.3 KiB
GDScript

class_name MoveData
## One move = one folder: frames/ (sprite sequence) + data.json (frame data).
## All boxes are in fighter-local space for a RIGHT-facing fighter:
## origin at the ground pivot (feet centre), +x forward, -y up.
## Rects are [x, y, w, h] with y typically negative (above the ground).
## Timing values are in 60Hz ticks. Sprite frames are spread evenly
## across `total` ticks, so art frame count is decoupled from game timing.
var name := ""
var total := 1
var loop := false
var startup := 0
var active_start := -1
var active_end := -1
var damage := 0
var hitstun := 0
var blockstun := 0
var pushback := 0.0
var knockdown := false
var cancels: Array = []
var root_motion := PackedFloat32Array() # per-tick forward dx, optional
var root_motion_frames := PackedFloat32Array() # per-ART-FRAME dx (from renders)
var frames: Array = [] # Array[Texture2D]
var frame_size := Vector2i.ZERO
var _hurt_default: Array = [] # Array[Rect2]
var _hurt_frames := {} # tick -> Array[Rect2]
var _hitboxes := {} # tick -> Array[Rect2]
static func _parse_rects(arr) -> Array:
var out: Array = []
if arr == null:
return out
for r in arr:
out.append(Rect2(r[0], r[1], r[2], r[3]))
return out
## Expand {"6-8": [...], "12": [...]} into a per-tick dictionary.
static func _parse_ranged(dict) -> Dictionary:
var out := {}
if dict == null:
return out
for key in dict:
var rects := _parse_rects(dict[key])
var parts := str(key).split("-")
var lo := int(parts[0])
var hi := int(parts[parts.size() - 1])
for t in range(lo, hi + 1):
out[t] = rects
return out
static func load_dir(dir_path: String) -> MoveData:
var m := MoveData.new()
var json_text := FileAccess.get_file_as_string(dir_path + "/data.json")
if json_text.is_empty():
push_error("MoveData: missing data.json in " + dir_path)
return m
var d = JSON.parse_string(json_text)
m.name = d.get("name", dir_path.get_file())
m.total = int(d.get("total", 1))
m.loop = bool(d.get("loop", false))
m.startup = int(d.get("startup", 0))
var act = d.get("active", null)
if act != null:
m.active_start = int(act[0])
m.active_end = int(act[1])
m.damage = int(d.get("damage", 0))
m.hitstun = int(d.get("hitstun", 0))
m.blockstun = int(d.get("blockstun", 0))
m.pushback = float(d.get("pushback", 0.0))
m.knockdown = bool(d.get("knockdown", false))
m.cancels = d.get("cancels", [])
for v in d.get("root_motion", []):
m.root_motion.append(float(v))
for v in d.get("root_motion_frames", []):
m.root_motion_frames.append(float(v))
var hurt = d.get("hurtboxes", {})
m._hurt_default = _parse_rects(hurt.get("default", []))
m._hurt_frames = _parse_ranged(hurt.get("frames", null))
m._hitboxes = _parse_ranged(d.get("hitboxes", null))
m._load_frames(dir_path + "/frames")
return m
## Frames load via Image.load_from_file so characters/ works as a plain
## drop-in folder — no editor import step needed for new characters.
func _load_frames(frames_path: String) -> void:
var global := ProjectSettings.globalize_path(frames_path)
var files := DirAccess.get_files_at(global)
var names: Array = []
for f in files:
if f.get_extension().to_lower() in ["png", "webp"]:
names.append(f)
names.sort()
for f in names:
var img := Image.load_from_file(global + "/" + f)
if img:
frames.append(ImageTexture.create_from_image(img))
if not frames.is_empty():
frame_size = frames[0].get_size()
func frame_texture(tick: int):
if frames.is_empty():
return null
return frames[mini(_frame_index(tick), frames.size() - 1)]
func is_active(tick: int) -> bool:
return active_start >= 0 and tick >= active_start and tick <= active_end
func hurtboxes_at(tick: int) -> Array:
return _hurt_frames.get(tick, _hurt_default)
func hitboxes_at(tick: int) -> Array:
return _hitboxes.get(tick, [])
func _frame_index(tick: int) -> int:
var t := tick % total if loop else mini(tick, total - 1)
return int(float(t) * frames.size() / total)
func root_dx(tick: int) -> float:
if tick < root_motion.size():
return root_motion[tick]
# frame-based root motion: apply a frame's dx on the tick it appears
if root_motion_frames.size() > 0 and frames.size() > 0:
var fi := _frame_index(tick)
if fi < root_motion_frames.size() and (tick == 0 or _frame_index(tick - 1) != fi):
return root_motion_frames[fi]
return 0.0