foitin/engine/core/move_data.gd
m3ultra d743216b54 Character select screen + fatality-style finisher system
- Select scene (new main scene): roster cards from characters/* with
  portraits, dual cursors (P1 A/D+J, P2 arrows+O), picks via GameState
- finisher flow: KO puts loser in DIZZY with a 5s flashing FINISH! window;
  winner lands QCF+K in range -> finisher move (Brutal Assassination /
  Hell Slammer B) with darkened stage, victim plays synced death anim;
  window expiry or any stray hit = normal collapse
- new moves rendered for both fighters in all wardrobe states:
  dizzy (Stunned), finisher, death (Standing Death Backward)
- CHAR_TUNE per-character move overrides in tune_fighters.py

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 23:27:51 +10:00

161 lines
5.4 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] (base wardrobe state)
var frame_variants := {} # state name -> Array[Texture2D]
var tears := false # landing this move tears clothing
var finisher := false # fatality-style finishing move
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))
m.tears = bool(d.get("tears", false))
m.finisher = bool(d.get("finisher", false))
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")
# wardrobe variants live in sibling dirs: frames@torn, frames@geared, ...
var move_global := ProjectSettings.globalize_path(dir_path)
for sub in DirAccess.get_directories_at(move_global):
if sub.begins_with("frames@"):
var state := sub.trim_prefix("frames@")
m.frame_variants[state] = _load_frame_dir(dir_path + "/" + sub)
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.
static func _load_frame_dir(frames_path: String) -> Array:
var out: Array = []
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:
out.append(ImageTexture.create_from_image(img))
return out
func _load_frames(frames_path: String) -> void:
frames = _load_frame_dir(frames_path)
if not frames.is_empty():
frame_size = frames[0].get_size()
## `state` picks a wardrobe variant; falls back to base frames when the
## variant is missing or shorter than the base set.
func frame_texture(tick: int, state: String = ""):
if frames.is_empty():
return null
var idx := mini(_frame_index(tick), frames.size() - 1)
if state != "" and frame_variants.has(state):
var v: Array = frame_variants[state]
if idx < v.size():
return v[idx]
return frames[idx]
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