foitin/engine/core/move_data.gd
m3ultra 12528b4de0 Title screen, FOITIN editor, pck-safe loaders, export presets
- Title: PLAY / EDITOR entry
- Editor (in-app): edit fighter name/stats/colour -> user://overrides,
  import character folders -> user://characters, add stage backgrounds
  -> user://stages, open content folder
- loaders work in editor, exported .pck, and user:// drop-ins
  (ResourceLoader remap-stripping + byte-buffer fallbacks)
- stage backgrounds render behind fights when present
- export_presets: mac (universal, ad-hoc sign), win x86_64, linux x86_64

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:02:37 +10:00

192 lines
6.6 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, ...
for sub in list_dirs(dir_path):
if sub.begins_with("frames@"):
var state := sub.trim_prefix("frames@")
m.frame_variants[state] = _load_frame_dir(dir_path + "/" + sub)
return m
## Directory listing that works for loose paths, user://, and packed res://.
static func list_dirs(path: String) -> PackedStringArray:
var dirs := DirAccess.get_directories_at(
path if path.begins_with("user://") else ProjectSettings.globalize_path(path))
if dirs.is_empty():
dirs = DirAccess.get_directories_at(path)
return dirs
## Frame loading works in three homes:
## - editor / dev run: res:// as loose files on disk
## - exported .pck: imported textures via ResourceLoader (listings show
## ".remap"/".import" suffixes — strip them)
## - user:// drop-in folders (mods / editor output): raw image bytes
static func _load_frame_dir(frames_path: String) -> Array:
var out: Array = []
var files := DirAccess.get_files_at(
frames_path if frames_path.begins_with("user://")
else ProjectSettings.globalize_path(frames_path))
if files.is_empty():
files = DirAccess.get_files_at(frames_path) # pck listing
var names: Array = []
for f in files:
var clean := f.trim_suffix(".remap").trim_suffix(".import")
if clean.get_extension().to_lower() in ["png", "webp"] and not names.has(clean):
names.append(clean)
names.sort()
for f in names:
var tex = load_texture_any(frames_path + "/" + f)
if tex:
out.append(tex)
return out
## Texture from a path that may be a packed resource or a loose file.
static func load_texture_any(path: String):
if ResourceLoader.exists(path):
var res = ResourceLoader.load(path)
if res is Texture2D:
return res
var bytes := FileAccess.get_file_as_bytes(path)
if bytes.is_empty():
var img := Image.load_from_file(ProjectSettings.globalize_path(path))
return ImageTexture.create_from_image(img) if img else null
var image := Image.new()
var err := image.load_png_from_buffer(bytes) if path.get_extension().to_lower() == "png" \
else image.load_webp_from_buffer(bytes)
return ImageTexture.create_from_image(image) if err == OK else null
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