Add missing stickman placeholder and stage scene I/O functionality

- Implemented `MissingStickmanPlaceholder` class to visually indicate missing stickman assets in the scene.
- Created `StageSceneIO` class for handling JSON serialization and deserialization of stage documents, including encoding and decoding of actions and rules.
- Added tests for vector handling, rule encoding/decoding, safe file writing, root validation, and handling of missing assets.
- Introduced a new stickman asset file `bad.stk` for testing purposes.
- Enhanced test coverage for phase 5 file features, ensuring robust handling of scene data and missing assets.
This commit is contained in:
2026-09-14 15:45:32 -04:00
parent 3a1d522ec9
commit a7c58705e1
15 changed files with 3247 additions and 26 deletions
+24
View File
@@ -0,0 +1,24 @@
class_name MissingStickmanPlaceholder
extends Node2D
var original_entry: Dictionary = {}
var missing_stk_path: String = ""
const MARKER_SIZE := Vector2(260.0, 52.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_stk_path = String(entry.get("stk_path", ""))
queue_redraw()
func get_area_rect() -> Rect2:
return Rect2(-MARKER_SIZE * 0.5, MARKER_SIZE)
func _draw() -> void:
var rect := get_area_rect()
draw_rect(rect, Color(0.76, 0.10, 0.10, 0.95), true)
draw_rect(rect, Color(1.0, 0.78, 0.78, 1.0), false, 2.0)
var asset_name := missing_stk_path.get_file()
if asset_name.is_empty():
asset_name = "Unnamed .stk asset"
draw_string(ThemeDB.fallback_font, rect.position + Vector2(10.0, 32.0), "Missing asset - %s" % asset_name, HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 20.0, 16, Color.WHITE)
@@ -0,0 +1 @@
uid://cd0b16vgbqyag
+3
View File
@@ -18,6 +18,9 @@ enum ShapeType { POLYGON, CIRCLE }
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
## Stable library provenance used by Sandbox Stage scene files.
var spawn_id: String = ""
# ---------------------------------------------------------------------------
# Child node names
# ---------------------------------------------------------------------------
+454 -3
View File
@@ -35,6 +35,8 @@ const RULE_PANEL := preload("res://scenes/rule_panel.tscn")
const ACTION_EDITOR := preload("res://scenes/action_editor.tscn")
const RULE_EDITOR := preload("res://scenes/rule_editor.tscn")
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -47,6 +49,7 @@ enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARG
## Phase 3c target-capture kinds (what the next stage click resolves to).
enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION }
enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT }
# ---------------------------------------------------------------------------
# Signals
@@ -72,6 +75,11 @@ const THEME_PATH := "res://sandbox_theme.json"
const DEFAULT_GRID_SIZE := 15.0
const MIN_GRID_SIZE := 1.0
const MAX_GRID_SIZE := 100.0
const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene"
const RECENT_SCENE_LIMIT: int = 5
const DEFAULT_SCENE_NAME: String = "Untitled"
const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0)
const DEFAULT_CAMERA_ZOOM: float = 0.5
## Director action-popup item ids (Phase 3a).
const ACT_WALK := 0
@@ -175,6 +183,21 @@ var _grid_size_label: Label
var _grid_size: float = DEFAULT_GRID_SIZE
var _snap_enabled: bool = false
var _show_grid: bool = true
var _current_scene_path: String = ""
var _scene_name: String = DEFAULT_SCENE_NAME
var _scene_created_unix: int = 0
var _dirty: bool = false
var _suppress_dirty: bool = false
var _recent_scenes: Array[String] = []
var _pending_file_action: PendingFileAction = PendingFileAction.NONE
var _pending_open_path: String = ""
var _file_menu: MenuButton = null
var _scene_open_dialog: FileDialog = null
var _scene_save_dialog: FileDialog = null
var _unsaved_changes_dialog: ConfirmationDialog = null
var _rename_scene_dialog: ConfirmationDialog = null
var _scene_name_edit: LineEdit = null
var _missing_assets_dialog: AcceptDialog = null
var _panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
@@ -365,6 +388,7 @@ var _rule_editor_from_panel: bool = false
# ---------------------------------------------------------------------------
func _ready() -> void:
get_tree().auto_accept_quit = false
_camera.make_current()
_load_theme()
_grid_size = _theme_grid_default
@@ -397,6 +421,8 @@ func _ready() -> void:
_refresh_mode_badge()
_apply_cursor()
_refresh_status()
_update_scene_chrome()
_check_autosave_recovery()
func _process(delta: float) -> void:
@@ -581,6 +607,9 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
func set_mode(mode: StageMode) -> void:
if mode == current_mode:
return
if mode == StageMode.PLAY:
if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.")
current_mode = mode
match mode:
StageMode.EDIT:
@@ -699,13 +728,20 @@ func _enter_play_mode() -> void:
func _save_object_state(node: Node2D) -> void:
var authored_rotation := node.global_rotation
if node is STICKMAN_RIG and (node as StickmanRig).get_facing_profile() == StickmanRig.FacingProfile.LEFT:
# LEFT is a derived root reflection. Remove it before persisting the
# director-authored rotation so a load never applies the mirror twice.
authored_rotation = (node.global_transform * Transform2D.FLIP_X).get_rotation()
var entry := {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
"rotation": authored_rotation,
}
if node is STICKMAN_RIG:
entry["facing_profile"] = (node as STICKMAN_RIG).get_facing_profile()
if not node is STICKMAN_RIG:
entry["scale"] = node.scale
_authored[node.get_instance_id()] = entry
@@ -757,6 +793,7 @@ func _place_at(world_pos: Vector2) -> void:
_nav_dirty = true
_rebuild_grid_cells()
object_placed.emit(node)
_mark_scene_dirty()
_refresh_status()
_spawn_ghost()
@@ -771,7 +808,7 @@ func _finalize_placed_node(node: Node2D) -> void:
_save_object_state(node)
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.queue_changed.connect(_director_visuals.mark_dirty)
rig.queue_changed.connect(_on_rig_queue_changed)
rig.arrived.connect(_on_rig_arrived.bind(rig))
rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_finished.bind(rig))
@@ -1033,6 +1070,7 @@ func _commit_terrain_drag() -> void:
if not placed.is_empty():
_nav_dirty = true
_rebuild_grid_cells()
_mark_scene_dirty()
_end_terrain_drag()
_refresh_status()
@@ -1150,12 +1188,276 @@ func delete_selected() -> void:
_nav_dirty = true
_director_visuals.mark_dirty()
object_deleted.emit(selected)
_mark_scene_dirty()
_refresh_status()
# ---------------------------------------------------------------------------
# Status / UI
# ---------------------------------------------------------------------------
func _mark_scene_dirty() -> void:
if not _suppress_dirty:
_set_scene_dirty(true)
func _set_scene_dirty(value: bool) -> void:
_dirty = value
_update_scene_chrome()
func _on_rig_queue_changed() -> void:
_director_visuals.mark_dirty()
_mark_scene_dirty()
func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
var objects: Array[Dictionary] = []
var runtime_to_file: Dictionary = {}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
for node: Node in _world.get_children():
if not node is Node2D: continue
var n := node as Node2D
var type := ""
if n is TerrainBlock: type = "terrain"
elif n is PropBlock: type = "prop"
elif n is STICKMAN_RIG or n.get_script() == MISSING_STICKMAN_PLACEHOLDER: type = "stickman"
elif n is TriggerArea: type = "area"
else: continue
counters[type] = int(counters[type]) + 1
var file_id := "%s_%d" % [type, int(counters[type])]
runtime_to_file[n.get_instance_id()] = file_id
var authored: Dictionary = _authored.get(n.get_instance_id(), {})
var pos: Vector2 = authored.get("position", n.global_position)
var rot: float = rad_to_deg(float(authored.get("rotation", n.global_rotation)))
var entry: Dictionary = {"file_id": file_id, "type": type, "position": STAGE_SCENE_IO.vector_to_data(pos), "rotation": rot}
if type == "terrain":
entry["asset_id"] = (n as TerrainBlock).spawn_id
entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
elif type == "prop":
var prop := n as PropBlock
entry["asset_id"] = prop.spawn_id
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop.scale)
entry["material_preset"] = prop.material_preset
elif type == "area":
var area := n as TriggerArea
entry["scale"] = STAGE_SCENE_IO.vector_to_data(area.scale)
entry["size"] = STAGE_SCENE_IO.vector_to_data(area.size)
elif n.get_script() == MISSING_STICKMAN_PLACEHOLDER:
entry = (n.get("original_entry") as Dictionary).duplicate(true)
entry["file_id"] = file_id; entry["type"] = "stickman"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot
else:
var rig := n as StickmanRig
entry["stk_path"] = rig.source_stk_path
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[int(authored.get("facing_profile", rig.get_facing_profile()))]
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
entry["action_queue"] = queue_result["actions"]
objects.append(entry)
var rules_result: Dictionary = STAGE_SCENE_IO.encode_rules(_event_rules, runtime_to_file)
return {"version": STAGE_SCENE_IO.FILE_VERSION, "scene_name": _scene_name, "camera": {"position": STAGE_SCENE_IO.vector_to_data(_camera.global_position), "zoom": _camera.zoom.x}, "objects": objects, "event_rules": rules_result["rules"], "metadata": {"created_unix": _scene_created_unix if _scene_created_unix > 0 else Time.get_unix_time_from_system(), "modified_unix": Time.get_unix_time_from_system(), "autosave": is_autosave, "source_path": _current_scene_path if is_autosave else ""}}
func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool:
var result: Dictionary = STAGE_SCENE_IO.write_file_safe(path, _collect_scene_data(is_autosave))
if not bool(result.get("ok", false)):
_show_toast(String(result.get("error", "Could not save scene.")))
if _pending_file_action != PendingFileAction.NONE: _pending_file_action = PendingFileAction.NONE
return false
if not is_autosave:
_current_scene_path = path
if _scene_created_unix <= 0: _scene_created_unix = Time.get_unix_time_from_system()
_set_scene_dirty(false); _add_recent_scene(path)
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
return true
func _reset_scene_content() -> void:
set_mode(StageMode.EDIT)
_free_ghost(); _free_terrain_ghosts(); _end_terrain_drag()
_selection.clear_selection(); _selection.clear_hover(); _authored.clear(); _edit_physics_targets.clear(); _event_rules.clear(); _next_rule_id = 0
_clear_director_pending(); _reset_rule_builder(); _nav_dirty = false
for child: Node in _world.get_children():
_world.remove_child(child)
child.queue_free()
_area_overlap.clear(); _collision_pairs.clear(); _grid_cells.clear(); _director_visuals.set_rules(_event_rules)
func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool:
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
if not bool(read.get("ok", false)):
_show_toast(String(read.get("error", "Could not load scene."))); return false
return _apply_scene_data(read["data"], path, is_recovery)
func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool:
var was_suppressed := _suppress_dirty; _suppress_dirty = true
_reset_scene_content()
var file_to_runtime: Dictionary = {}; var unavailable: Dictionary = {}; var warnings: Array[String] = []; var missing_stickmen := 0; var missing_paths: Array[String] = []
for raw: Variant in data.get("objects", []):
if not raw is Dictionary: warnings.append("Malformed object"); continue
var entry: Dictionary = raw; var file_id := String(entry.get("file_id", "")); var type := String(entry.get("type", ""))
if file_id.is_empty() or file_to_runtime.has(file_id) or unavailable.has(file_id): warnings.append("Duplicate or empty file id"); continue
if not STAGE_SCENE_IO.is_vector_data(entry.get("position")) or not STAGE_SCENE_IO._finite_number(entry.get("rotation", 0.0)):
warnings.append("Malformed object transform"); continue
var pos := STAGE_SCENE_IO.data_to_vector(entry.get("position"), Vector2.ZERO)
var node: Node2D = null
match type:
"terrain": node = _spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), pos)
"prop": node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
"area": node = _spawner.spawn_area(pos)
"stickman":
node = _spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), pos)
if node == null:
var placeholder: Node2D = MISSING_STICKMAN_PLACEHOLDER.new() as Node2D
placeholder.configure(entry); placeholder.position = pos; _world.add_child(placeholder); node = placeholder; unavailable[file_id] = true
missing_stickmen += 1
var missing_path := String(entry.get("stk_path", ""))
if not missing_paths.has(missing_path): missing_paths.append(missing_path)
if node == null: warnings.append("Unknown object asset"); continue
node.global_rotation = deg_to_rad(float(entry.get("rotation", 0.0)))
if type != "stickman":
if entry.has("scale") and not STAGE_SCENE_IO.is_vector_data(entry["scale"]):
warnings.append("Malformed object scale")
_world.remove_child(node)
node.queue_free()
continue
node.scale = STAGE_SCENE_IO.data_to_vector(entry.get("scale"), Vector2.ONE)
if node is TriggerArea:
var area_size := STAGE_SCENE_IO.data_to_vector(entry.get("size"), Vector2.ZERO)
if not STAGE_SCENE_IO.is_vector_data(entry.get("size")) or area_size.x <= 0.0 or area_size.y <= 0.0:
warnings.append("Malformed trigger area")
_world.remove_child(node)
node.queue_free()
continue
(node as TriggerArea).size = area_size
if node is PropBlock:
var preset_names: Array = PropBlock.MaterialPreset.keys()
var raw_preset: Variant = entry.get("material_preset", PropBlock.MaterialPreset.NONE)
var index := -1
if raw_preset is int or raw_preset is float:
var numeric_preset := float(raw_preset)
if is_finite(numeric_preset) and is_equal_approx(numeric_preset, roundf(numeric_preset)):
index = int(numeric_preset)
else:
index = preset_names.find(String(raw_preset))
if index >= 0 and index < preset_names.size():
(node as PropBlock).material_preset = index
else:
warnings.append("Invalid prop material preset")
if node is StickmanRig:
var facing_names: Array = StickmanRig.FacingProfile.keys(); var facing := facing_names.find(String(entry.get("facing_profile", "FORWARD")))
if facing >= 0: (node as StickmanRig).set_facing_profile(facing)
var raw_queue: Variant = entry.get("action_queue", [])
if raw_queue is Array:
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
for warning: String in queue.get("warnings", []):
warnings.append(warning)
for action: Dictionary in queue["actions"]:
(node as StickmanRig).queue_action(action)
else:
warnings.append("Invalid stickman action queue")
_finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id()
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
_event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0
for rule: Dictionary in _event_rules: _next_rule_id = maxi(_next_rule_id, int(rule["id"]) + 1)
_director_visuals.set_rules(_event_rules)
var raw_camera: Variant = data.get("camera", {})
var camera: Dictionary = raw_camera as Dictionary if raw_camera is Dictionary else {}
_camera.global_position = STAGE_SCENE_IO.data_to_vector(camera.get("position"), DEFAULT_CAMERA_POSITION)
_camera.zoom = Vector2.ONE * clampf(float(camera.get("zoom", DEFAULT_CAMERA_ZOOM)), min_zoom, max_zoom)
_scene_name = String(data.get("scene_name", DEFAULT_SCENE_NAME)).strip_edges(); if _scene_name.is_empty(): _scene_name = DEFAULT_SCENE_NAME
var raw_metadata: Variant = data.get("metadata", {})
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
_scene_created_unix = int(metadata.get("created_unix", Time.get_unix_time_from_system()))
_current_scene_path = String(metadata.get("source_path", source_path)) if is_recovery else source_path
_rebuild_grid_cells(); _nav_dirty = true; _suppress_dirty = was_suppressed; _set_scene_dirty(is_recovery)
if not warnings.is_empty() or int(decoded_rules.get("skipped", 0)) > 0 or missing_stickmen > 0:
_show_toast("Loaded with %d warning(s), %d missing stickman, %d skipped rule(s)" % [warnings.size(), missing_stickmen, int(decoded_rules.get("skipped", 0))])
if not missing_paths.is_empty():
_show_missing_assets_dialog(missing_paths)
if not is_recovery:
_add_recent_scene(source_path)
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
return true
func _show_missing_assets_dialog(paths: Array[String]) -> void:
if _missing_assets_dialog == null:
return
var asset_lines: Array[String] = []
for path: String in paths:
var asset_name := path.get_file()
if asset_name.is_empty(): asset_name = "Unnamed .stk asset"
asset_lines.append("• %s\n %s" % [asset_name, path if not path.is_empty() else "(no asset path was saved)"])
_missing_assets_dialog.dialog_text = "%d stickman asset(s) could not be loaded:\n\n%s\n\nA red marker was placed where each missing asset belongs." % [paths.size(), "\n\n".join(asset_lines)]
_missing_assets_dialog.popup_centered()
func _add_recent_scene(path: String) -> void:
if path.is_empty() or path == AUTOSAVE_PATH: return
_recent_scenes.erase(path); _recent_scenes.push_front(path)
while _recent_scenes.size() > RECENT_SCENE_LIMIT: _recent_scenes.pop_back()
_save_settings()
_update_recent_menu()
func _update_recent_menu() -> void:
if _file_menu == null: return
var recent := _file_menu.get_popup().get_node_or_null(NodePath("OpenRecent")) as PopupMenu
if recent == null: return
recent.clear()
var existing: Array[String] = []
for path: String in _recent_scenes:
if FileAccess.file_exists(path): existing.append(path)
var pruned := existing.size() != _recent_scenes.size()
_recent_scenes = existing
if pruned: _save_settings()
if _recent_scenes.is_empty():
recent.add_item("(No recent scenes)", -1); recent.set_item_disabled(0, true)
else:
for index: int in _recent_scenes.size(): recent.add_item(_recent_scenes[index], index)
func _on_recent_scene_selected(index: int) -> void:
if index < 0 or index >= _recent_scenes.size(): return
var path := _recent_scenes[index]
if not FileAccess.file_exists(path):
_recent_scenes.remove_at(index); _save_settings(); _show_toast("File not found: %s" % path); return
_request_pending_file_action(PendingFileAction.OPEN_PATH, path)
func _update_scene_chrome() -> void:
if is_inside_tree():
var scene_file := _current_scene_path.get_file()
if scene_file.is_empty():
scene_file = "Unsaved"
get_window().title = "%s%s — %s — Stickman Studio" % [_scene_name, " *" if _dirty else "", scene_file]
_refresh_status()
func _check_autosave_recovery() -> void:
if not FileAccess.file_exists(AUTOSAVE_PATH): return
var read: Dictionary = STAGE_SCENE_IO.read_file(AUTOSAVE_PATH)
if not bool(read.get("ok", false)):
DirAccess.remove_absolute(AUTOSAVE_PATH); return
var autosave_data: Dictionary = read["data"] as Dictionary
var raw_metadata: Variant = autosave_data.get("metadata", {})
var metadata: Dictionary = raw_metadata as Dictionary if raw_metadata is Dictionary else {}
if not bool(metadata.get("autosave", false)):
DirAccess.remove_absolute(AUTOSAVE_PATH); return
var source_path := String(metadata.get("source_path", ""))
if not source_path.is_empty() and FileAccess.file_exists(source_path) and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)):
DirAccess.remove_absolute(AUTOSAVE_PATH); return
var dialog := ConfirmationDialog.new()
dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?"
dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free())
dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free())
get_node(NodePath("UI")).add_child(dialog); dialog.popup_centered()
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_pending_file_action(PendingFileAction.QUIT)
func _refresh_status() -> void:
if _status_label == null:
return
@@ -1168,7 +1470,7 @@ func _refresh_status() -> void:
sel_text = String(sel[0].name)
else:
sel_text = "%d objects" % sel.size()
var text := "Objects: %d | Selected: %s" % [count, sel_text]
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
if _pending_walk_target:
text += " | Click stage for walk target (Esc to cancel)"
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
@@ -1333,9 +1635,28 @@ func _build_ui() -> void:
ui.add_child(top_bar)
var hbox := HBoxContainer.new()
hbox.name = "Content"
hbox.add_theme_constant_override("separation", 8)
top_bar.add_child(hbox)
_file_menu = MenuButton.new()
_file_menu.name = "FileMenu"
_file_menu.text = "File"
var file_popup := _file_menu.get_popup()
file_popup.add_item("New", 0)
file_popup.add_item("Open…", 1)
var recent_popup := PopupMenu.new()
recent_popup.name = "OpenRecent"
recent_popup.id_pressed.connect(_on_recent_scene_selected)
file_popup.add_child(recent_popup)
file_popup.add_submenu_item("Open Recent", "OpenRecent")
file_popup.add_item("Save", 2)
file_popup.add_item("Save As…", 3)
file_popup.add_item("Rename Scene…", 4)
file_popup.id_pressed.connect(_on_file_menu_id_pressed)
file_popup.about_to_popup.connect(_update_recent_menu)
hbox.add_child(_file_menu)
# 3-segment mode switcher.
var mode_defs: Array = [
[StageMode.EDIT, "✏️ Edit"],
@@ -1549,6 +1870,50 @@ func _build_ui() -> void:
_browse_dialog.file_selected.connect(_on_browse_file_selected)
ui.add_child(_browse_dialog)
_scene_open_dialog = FileDialog.new()
_scene_open_dialog.name = "SceneOpenDialog"
_scene_open_dialog.access = FileDialog.ACCESS_FILESYSTEM
_scene_open_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
_scene_open_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
_scene_open_dialog.file_selected.connect(func(path: String) -> void: _load_scene_from_path(path))
ui.add_child(_scene_open_dialog)
_scene_save_dialog = FileDialog.new()
_scene_save_dialog.name = "SceneSaveDialog"
_scene_save_dialog.access = FileDialog.ACCESS_FILESYSTEM
_scene_save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE
_scene_save_dialog.filters = PackedStringArray(["*.scene ; Stickman Scene Files"])
_scene_save_dialog.file_selected.connect(_on_scene_save_path_selected)
_scene_save_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
ui.add_child(_scene_save_dialog)
_rename_scene_dialog = ConfirmationDialog.new()
_rename_scene_dialog.name = "RenameSceneDialog"
_rename_scene_dialog.title = "Rename Scene"
_rename_scene_dialog.cancel_button_text = "Cancel"
_rename_scene_dialog.min_size = Vector2i(540, 0)
_scene_name_edit = LineEdit.new()
_scene_name_edit.name = "SceneNameEdit"
_scene_name_edit.placeholder_text = "Scene name"
_scene_name_edit.custom_minimum_size = Vector2(460.0, 0.0)
_rename_scene_dialog.add_child(_scene_name_edit)
_rename_scene_dialog.confirmed.connect(func() -> void:
var scene_title := _scene_name_edit.text.strip_edges()
if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty())
ui.add_child(_rename_scene_dialog)
_missing_assets_dialog = AcceptDialog.new()
_missing_assets_dialog.name = "MissingAssetsDialog"
_missing_assets_dialog.title = "Missing Scene Assets"
_missing_assets_dialog.min_size = Vector2i(560, 0)
ui.add_child(_missing_assets_dialog)
_unsaved_changes_dialog = ConfirmationDialog.new()
_unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Save changes before continuing?"; _unsaved_changes_dialog.ok_button_text = "Save"
_unsaved_changes_dialog.add_button("Discard", false, "discard")
_unsaved_changes_dialog.confirmed.connect(_on_unsaved_save)
_unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard())
_unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
ui.add_child(_unsaved_changes_dialog)
# ---- Phase 3c editor tools ----
_rig_context_popup = PopupMenu.new()
@@ -1867,6 +2232,77 @@ func _on_transform_committed(nodes: Array[Node2D]) -> void:
_rebuild_grid_cells()
_director_visuals.mark_dirty()
_edit_physics_settle_frames = 3
_mark_scene_dirty()
func _on_file_menu_id_pressed(id: int) -> void:
match id:
0:
_request_pending_file_action(PendingFileAction.NEW_SCENE)
1:
_request_pending_file_action(PendingFileAction.OPEN_DIALOG)
2:
if _current_scene_path.is_empty(): _scene_save_dialog.popup_centered_ratio(0.7)
else: _save_scene_to_path(_current_scene_path)
3: _scene_save_dialog.popup_centered_ratio(0.7)
4: _rename_scene_prompt()
func _on_scene_save_path_selected(path: String) -> void:
var final_path := path
if final_path.get_extension().to_lower() != "scene": final_path = final_path.get_basename() + ".scene"
if _save_scene_to_path(final_path) and _pending_file_action != PendingFileAction.NONE:
_continue_pending_file_action()
func _rename_scene_prompt() -> void:
_scene_name_edit.text = _scene_name
_rename_scene_dialog.popup_centered()
call_deferred("_focus_scene_name_edit")
func _focus_scene_name_edit() -> void:
if _scene_name_edit == null or not _scene_name_edit.visible:
return
_scene_name_edit.grab_focus()
_scene_name_edit.select_all()
func _request_pending_file_action(action: PendingFileAction, path: String = "") -> void:
_pending_file_action = action; _pending_open_path = path
if _dirty:
_unsaved_changes_dialog.popup_centered()
else:
_continue_pending_file_action()
func _continue_pending_file_action() -> void:
if _unsaved_changes_dialog != null:
_unsaved_changes_dialog.hide()
var action := _pending_file_action; var path := _pending_open_path
_pending_file_action = PendingFileAction.NONE; _pending_open_path = ""
match action:
PendingFileAction.NEW_SCENE:
_reset_scene_content(); _current_scene_path = ""; _scene_name = DEFAULT_SCENE_NAME; _scene_created_unix = Time.get_unix_time_from_system(); _camera.global_position = DEFAULT_CAMERA_POSITION; _camera.zoom = Vector2.ONE * DEFAULT_CAMERA_ZOOM; _set_scene_dirty(false)
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
PendingFileAction.OPEN_DIALOG: _scene_open_dialog.popup_centered_ratio(0.7)
PendingFileAction.OPEN_PATH: _load_scene_from_path(path)
PendingFileAction.QUIT:
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
get_tree().quit()
func _on_unsaved_save() -> void:
_unsaved_changes_dialog.hide()
if _current_scene_path.is_empty():
_scene_save_dialog.popup_centered_ratio(0.7)
return
if _save_scene_to_path(_current_scene_path): _continue_pending_file_action()
func _on_unsaved_discard() -> void:
_unsaved_changes_dialog.hide()
_continue_pending_file_action()
func _on_gizmo_transform_started(_nodes: Array[Node2D]) -> void:
@@ -2419,6 +2855,7 @@ func _finalize_rule() -> void:
_event_rules.append(rule)
_show_toast("Rule created!")
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_reset_rule_builder()
_restore_rule_build_panel()
_refresh_status()
@@ -2454,6 +2891,7 @@ func _delete_rule(id: int) -> void:
_event_rules = _event_rules.filter(func(r): return int(r.get("id", -1)) != id)
if _event_rules.size() != before:
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_refresh_status()
@@ -3041,6 +3479,7 @@ func _on_rule_panel_clear_requested() -> void:
_ask_confirm("Clear Rules", "Remove all %d rule(s)?" % ids.size(), func() -> void:
_event_rules = _event_rules.filter(func(r): return not ids.has(int(r.get("id", -1))))
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_refresh_rule_panel()
)
@@ -3048,6 +3487,7 @@ func _on_rule_panel_clear_requested() -> void:
func _on_rule_panel_reorder_requested(ordered_ids: Array[int]) -> void:
_reorder_filtered_rules(ordered_ids)
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
## Rewrites the filtered rules (whose ids are in ordered_ids) in the new order
@@ -3113,6 +3553,7 @@ func _on_rule_editor_committed(rule: Dictionary) -> void:
_event_rules.append(rule)
_show_toast("Rule created!")
_director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_restore_rule_panel()
@@ -3496,6 +3937,15 @@ func _load_settings() -> void:
_grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
_snap_enabled = bool(d.get("snap_to_grid", false))
_show_grid = bool(d.get("show_grid", true))
_recent_scenes.clear()
var raw_recents: Variant = d.get("recent_scenes", [])
if raw_recents is Array:
for raw: Variant in raw_recents:
var path := String(raw)
if not path.is_empty() and FileAccess.file_exists(path) and not _recent_scenes.has(path):
_recent_scenes.append(path)
if _recent_scenes.size() >= RECENT_SCENE_LIMIT:
break
func _save_settings() -> void:
@@ -3504,6 +3954,7 @@ func _save_settings() -> void:
"grid_size": _grid_size,
"snap_to_grid": _snap_enabled,
"show_grid": _show_grid,
"recent_scenes": _recent_scenes,
}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
+240
View File
@@ -0,0 +1,240 @@
class_name StageSceneIO
extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.0"
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
static func vector_to_data(value: Vector2) -> Dictionary:
return {"x": value.x, "y": value.y}
static func is_vector_data(value: Variant) -> bool:
if not value is Dictionary:
return false
var data: Dictionary = value
return _finite_number(data.get("x")) and _finite_number(data.get("y"))
static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
if not is_vector_data(value):
return fallback
var data: Dictionary = value
return Vector2(float(data["x"]), float(data["y"]))
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for action: Dictionary in actions:
var encoded := _encode_flat_action(action)
if encoded.is_empty():
warnings.append("Skipped invalid queue action.")
else:
out.append(encoded)
return {"actions": out, "warnings": warnings}
static func decode_queue(entries: Array) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for entry: Variant in entries:
if not entry is Dictionary:
warnings.append("Skipped malformed queue action.")
continue
var decoded := _decode_flat_action(entry as Dictionary)
if decoded.is_empty():
warnings.append("Skipped invalid queue action.")
else:
out.append(decoded)
return {"actions": out, "warnings": warnings}
static func encode_rules(rules: Array[Dictionary], runtime_to_file: Dictionary) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for rule: Dictionary in rules:
var trigger: Variant = rule.get("trigger", {})
if not trigger is Dictionary:
warnings.append("Skipped malformed rule.")
continue
var encoded_trigger := _encode_trigger(trigger as Dictionary, runtime_to_file)
if encoded_trigger.is_empty():
warnings.append("Skipped invalid rule trigger.")
continue
var encoded_actions: Array[Dictionary] = []
var valid := true
var actions: Variant = rule.get("actions", [])
if not actions is Array:
valid = false
else:
for action: Variant in actions:
if not action is Dictionary:
valid = false
break
var encoded := _encode_rule_action(action as Dictionary, runtime_to_file)
if encoded.is_empty():
valid = false
break
encoded_actions.append(encoded)
if not valid:
warnings.append("Skipped invalid rule action.")
continue
out.append({"id": int(rule.get("id", -1)), "trigger": encoded_trigger, "actions": encoded_actions})
return {"rules": out, "warnings": warnings}
static func decode_rules(entries: Array, file_to_runtime: Dictionary, unavailable_ids: Dictionary) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
var skipped := 0
var used_ids: Dictionary = {}
var next_id := 0
for entry: Variant in entries:
if not entry is Dictionary:
warnings.append("Skipped malformed rule."); skipped += 1; continue
var d: Dictionary = entry
var trigger_value: Variant = d.get("trigger", {})
if not trigger_value is Dictionary:
warnings.append("Skipped malformed rule."); skipped += 1; continue
var trigger := _decode_trigger(trigger_value as Dictionary, file_to_runtime, unavailable_ids)
if trigger.is_empty():
warnings.append("Skipped unresolved rule."); skipped += 1; continue
var decoded_actions: Array[Dictionary] = []
var valid := d.get("actions") is Array
if valid:
for action: Variant in d["actions"]:
if not action is Dictionary:
valid = false; break
var decoded := _decode_rule_action(action as Dictionary, file_to_runtime, unavailable_ids)
if decoded.is_empty():
valid = false; break
decoded_actions.append(decoded)
if not valid:
warnings.append("Skipped unresolved rule."); skipped += 1; continue
var id := int(d.get("id", -1))
if id < 0 or used_ids.has(id):
while used_ids.has(next_id): next_id += 1
id = next_id
used_ids[id] = true
out.append({"id": id, "trigger": trigger, "actions": decoded_actions})
return {"rules": out, "warnings": warnings, "skipped": skipped}
static func read_file(path: String) -> Dictionary:
_recover_backup(path)
if not FileAccess.file_exists(path): return {"ok": false, "data": {}, "error": "File not found: %s" % path, "warnings": []}
var file := FileAccess.open(path, FileAccess.READ)
if file == null: return {"ok": false, "data": {}, "error": "Could not open scene file.", "warnings": []}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary: return {"ok": false, "data": {}, "error": "Invalid scene JSON.", "warnings": []}
var data := _migrate_to_current(parsed as Dictionary)
if data.is_empty(): return {"ok": false, "data": {}, "error": "Unsupported or invalid scene version.", "warnings": []}
if not data.get("objects") is Array or not data.get("event_rules") is Array:
return {"ok": false, "data": {}, "error": "Scene is missing objects or event_rules arrays.", "warnings": []}
var validation: Dictionary = _validate_root(data)
if not bool(validation.get("ok", false)): return {"ok": false, "data": {}, "error": String(validation.get("error", "Invalid scene data.")), "warnings": []}
return {"ok": true, "data": data, "error": "", "warnings": []}
static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
var tmp := path + ".tmp"
var bak := path + ".bak"
var file := FileAccess.open(tmp, FileAccess.WRITE)
if file == null: return {"ok": false, "error": "Could not write temporary scene file."}
file.store_string(JSON.stringify(data, "\t", false))
file.flush(); file.close()
if FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
DirAccess.remove_absolute(tmp); return {"ok": false, "error": "Could not back up existing scene file."}
if DirAccess.rename_absolute(tmp, path) != OK:
if FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path)
return {"ok": false, "error": "Could not replace scene file."}
if FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
return {"ok": true, "error": ""}
static func _migrate_to_current(data: Dictionary) -> Dictionary:
if String(data.get("version", "")) != FILE_VERSION: return {}
return data
static func _validate_root(data: Dictionary) -> Dictionary:
var seen: Dictionary = {}
for raw: Variant in data["objects"]:
if not raw is Dictionary: continue
var entry: Dictionary = raw
var file_id := String(entry.get("file_id", ""))
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
seen[file_id] = true
return {"ok": true, "error": ""}
static func _recover_backup(path: String) -> void:
var bak := path + ".bak"
if not FileAccess.file_exists(path) and FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path)
elif FileAccess.file_exists(path) and FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
if FileAccess.file_exists(path + ".tmp"): DirAccess.remove_absolute(path + ".tmp")
static func _finite_number(value: Variant) -> bool:
return (value is float or value is int) and is_finite(float(value))
static func _encode_flat_action(action: Dictionary) -> Dictionary:
var type := String(action.get("type", ""))
if not ACTION_REGISTRY.has_type(type): return {}
match type:
"walk_to":
if not action.get("target") is Vector2: return {}
return {"type": type, "target": vector_to_data(action["target"])}
"speak": return {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false))} if _finite_number(action.get("duration", 2.0)) else {}
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
var type := String(action.get("type", ""))
if not ACTION_REGISTRY.has_type(type): return {}
match type:
"walk_to":
if not is_vector_data(action.get("target")): return {}
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO)}
"speak":
if not _finite_number(action.get("duration", 2.0)): return {}
var out := {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0))}
if bool(action.get("stop_current", false)): out["stop_current"] = true
return out
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
_: return {"type": type}
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
if int(value) == -1: return null
return map.get(int(value), null)
static func _unmap_ref(value: Variant, map: Dictionary, unavailable: Dictionary) -> int:
if value == null: return -1
if not value is String or unavailable.has(value) or not map.has(value): return -2
return int(map[value])
static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary:
var type := String(trigger.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs)
if source == null and int(trigger.get("source", -1)) != -1: return {}
if target == null and int(trigger.get("target", -1)) != -1: return {}
var params: Dictionary = trigger.get("params", {})
if type == "arrived_at_waypoint":
if not params.get("waypoint_pos") is Vector2: return {}
params = {"waypoint_pos": vector_to_data(params["waypoint_pos"])}
return {"type": type, "source_id": source, "target_id": target, "params": params}
static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
var type := String(value.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
var source := _unmap_ref(value.get("source_id"), refs, unavailable); var target := _unmap_ref(value.get("target_id"), refs, unavailable)
if source == -2 or target == -2: return {}
var params: Dictionary = value.get("params", {})
if type == "arrived_at_waypoint":
if not is_vector_data(params.get("waypoint_pos")): return {}
params = {"waypoint_pos": data_to_vector(params["waypoint_pos"], Vector2.ZERO)}
return {"type": type, "source": source, "target": target, "params": params}
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
var id: Variant = _map_ref(action.get("target", -1), refs); if id == null and int(action.get("target", -1)) != -1: return {}
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action)); if flat.is_empty(): return {}
var params := flat.duplicate(); params.erase("type")
return {"type": String(flat["type"]), "target_id": id, "params": params}
static func _decode_rule_action(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
var target := _unmap_ref(value.get("target_id"), refs, unavailable); if target == -2: return {}
var flat: Dictionary = value.get("params", {}).duplicate() if value.get("params") is Dictionary else {}
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat); if decoded.is_empty(): return {}
return ACTION_REGISTRY.to_rule_action(decoded, target)
+1
View File
@@ -0,0 +1 @@
uid://bwmd6m4bdwm0m
+48 -23
View File
@@ -127,18 +127,60 @@ func spawn(id: String, world_position: Vector2) -> Node2D:
var pos := world_position + offset
match String(entry.get("kind", "")):
"terrain":
return _spawn_terrain(entry, pos)
return spawn_terrain_asset(id, pos)
"prop":
return _spawn_prop(entry, pos)
return spawn_prop_asset(selected_prop_id, pos)
"stickman":
return _spawn_stickman(pos)
return spawn_stickman_asset(selected_stickman_path, pos)
"area":
return _spawn_area(pos)
return spawn_area(pos)
_:
push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", ""))
return null
## Load-safe factories take real object origins and never apply palette offsets.
func spawn_terrain_asset(asset_id: String, object_position: Vector2) -> TerrainBlock:
var entry := _find_entry(asset_id)
if entry.is_empty() or String(entry.get("kind", "")) != "terrain":
push_warning("StageSpawner: unknown terrain '%s'." % asset_id)
return null
return _spawn_terrain(entry, object_position)
func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
var template: Dictionary = PROP_LIBRARY.get_entry(asset_id)
if template.is_empty():
push_warning("StageSpawner: unknown prop '%s'." % asset_id)
return null
var prop: PropBlock = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
prop.spawn_id = asset_id
return prop
func spawn_stickman_asset(stk_path: String, root_position: Vector2) -> StickmanRig:
var normalized := stk_path
var data: Dictionary = _stickman_cache.get(normalized, {})
if data.is_empty():
data = STICKMAN_FACTORY.load_stk(normalized)
_stickman_cache[normalized] = data
if data.is_empty():
push_warning("StageSpawner: no stickman data for '%s'." % normalized)
return null
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data)
if rig == null:
push_warning("StageSpawner: failed to spawn stickman.")
return null
rig.position = root_position
rig.source_stk_path = normalized
_world.add_child(rig)
return rig
func spawn_area(object_position: Vector2) -> TriggerArea:
return _spawn_area(object_position) as TriggerArea
## World-space AABB for a spawnable node: union of the Polygon2D child's world
## points; for a stickman rig, union of its mounted `Body/*` shape geometry; a
## point rect otherwise.
@@ -257,11 +299,7 @@ func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
var t: Dictionary = PROP_LIBRARY.get_entry(selected_prop_id)
if t.is_empty():
push_warning("StageSpawner: unknown selected prop '%s'." % selected_prop_id)
return null
return PROP_UTILS.spawn_prop(_world, world_position, t["payload"], int(t["material_preset"]), Vector2.ZERO)
return spawn_prop_asset(String(entry.get("id", selected_prop_id)), world_position)
func _spawn_area(world_position: Vector2) -> Node2D:
@@ -273,20 +311,7 @@ func _spawn_area(world_position: Vector2) -> Node2D:
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
var data: Dictionary = _stickman_cache.get(selected_stickman_path, {})
if data.is_empty():
data = STICKMAN_FACTORY.load_stk(selected_stickman_path)
_stickman_cache[selected_stickman_path] = data
if data.is_empty():
push_warning("StageSpawner: no stickman data for '%s'." % selected_stickman_path)
return null
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data)
if rig == null:
push_warning("StageSpawner: failed to spawn stickman.")
return null
rig.position = world_position
_world.add_child(rig)
return rig
return spawn_stickman_asset(selected_stickman_path, world_position)
static func _points_center(pts: PackedVector2Array) -> Vector2:
+3
View File
@@ -478,6 +478,9 @@ var _spawn_limb_rotations: Dictionary = {}
var action_queue: Array[Dictionary] = []
## Original .stk asset path, assigned by StageSpawner for scene persistence.
var source_stk_path: String = ""
var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1