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:
+454
-3
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user