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 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)) 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