- 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>
192 lines
6.6 KiB
GDScript
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
|