From a7c58705e17db37f35f36e83bfa2148605160e4b Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Mon, 14 Sep 2026 15:45:32 -0400 Subject: [PATCH] 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. --- game_scenes/test_scene.scene | 501 +++++++++++++++++++ game_scenes/test_scene2.scene | 489 +++++++++++++++++++ game_scenes/test_scene3.scene | 489 +++++++++++++++++++ plans/PHASE_5_FILE_FEATURES.md | 506 ++++++++++++++++++++ scripts/missing_stickman_placeholder.gd | 24 + scripts/missing_stickman_placeholder.gd.uid | 1 + scripts/prop_block.gd | 3 + scripts/sandbox_stage.gd | 457 +++++++++++++++++- scripts/stage_scene_io.gd | 240 ++++++++++ scripts/stage_scene_io.gd.uid | 1 + scripts/stage_spawner.gd | 71 ++- scripts/stickman_rig.gd | 3 + stickmen/bad.stk | 246 ++++++++++ tests/test_phase5_file_features.gd | 241 ++++++++++ tests/test_phase5_file_features.gd.uid | 1 + 15 files changed, 3247 insertions(+), 26 deletions(-) create mode 100644 game_scenes/test_scene.scene create mode 100644 game_scenes/test_scene2.scene create mode 100644 game_scenes/test_scene3.scene create mode 100644 plans/PHASE_5_FILE_FEATURES.md create mode 100644 scripts/missing_stickman_placeholder.gd create mode 100644 scripts/missing_stickman_placeholder.gd.uid create mode 100644 scripts/stage_scene_io.gd create mode 100644 scripts/stage_scene_io.gd.uid create mode 100644 stickmen/bad.stk create mode 100644 tests/test_phase5_file_features.gd create mode 100644 tests/test_phase5_file_features.gd.uid diff --git a/game_scenes/test_scene.scene b/game_scenes/test_scene.scene new file mode 100644 index 0000000..c558b61 --- /dev/null +++ b/game_scenes/test_scene.scene @@ -0,0 +1,501 @@ +{ + "version": "1.0", + "scene_name": "Basic Scene", + "camera": { + "position": { + "x": -310.933715820313, + "y": -1508.673828125 + }, + "zoom": 0.341506749391556 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -1920.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -1728.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -1536.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -1344.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -1152.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": -960.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": -768.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": -576.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": -384.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": -192.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 0.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 192.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 384.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": 576.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_15", + "type": "terrain", + "position": { + "x": 768.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_16", + "type": "terrain", + "position": { + "x": 960.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_17", + "type": "terrain", + "position": { + "x": 1280.0, + "y": 512.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_18", + "type": "terrain", + "position": { + "x": 1536.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_19", + "type": "terrain", + "position": { + "x": 1792.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_20", + "type": "terrain", + "position": { + "x": 2048.0, + "y": -256.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_21", + "type": "terrain", + "position": { + "x": 2304.0, + "y": -512.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_22", + "type": "terrain", + "position": { + "x": 2560.0, + "y": -768.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -1312.0, + "y": 255.0 + }, + "rotation": 0.0, + "stk_path": "res://stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "walk_to", + "target": { + "x": 1004.0, + "y": 600.0 + } + } + ] + }, + { + "file_id": "prop_1", + "type": "prop", + "position": { + "x": -1536.0, + "y": -768.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_2", + "type": "prop", + "position": { + "x": -512.0, + "y": -864.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_3", + "type": "prop", + "position": { + "x": -1184.0, + "y": -512.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_4", + "type": "prop", + "position": { + "x": -512.0, + "y": -1248.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_5", + "type": "prop", + "position": { + "x": 512.0, + "y": -1696.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_6", + "type": "prop", + "position": { + "x": 864.0, + "y": -1440.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_7", + "type": "prop", + "position": { + "x": 1248.0, + "y": -1728.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_8", + "type": "prop", + "position": { + "x": 1632.0, + "y": -1408.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "stickman_2", + "type": "stickman", + "position": { + "x": -160.0, + "y": -2177.0 + }, + "rotation": 0.0, + "stk_path": "res://stickmen/bad.stk", + "facing_profile": "FORWARD", + "action_queue": [] + } + ], + "event_rules": [ + { + "id": 0, + "trigger": { + "type": "collided", + "source_id": "stickman_1", + "target_id": "prop_3", + "params": {} + }, + "actions": [ + { + "type": "speak", + "target_id": "stickman_1", + "params": { + "text": "Ouch Man!", + "duration": 2.0, + "stop_current": false + } + } + ] + } + ], + "metadata": { + "created_unix": 1789411529, + "modified_unix": 1789413086.338, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/game_scenes/test_scene2.scene b/game_scenes/test_scene2.scene new file mode 100644 index 0000000..3e6b14c --- /dev/null +++ b/game_scenes/test_scene2.scene @@ -0,0 +1,489 @@ +{ + "version": "1.0", + "scene_name": "Test Scene", + "camera": { + "position": { + "x": 0.0, + "y": -400.0 + }, + "zoom": 0.5 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -1920.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -1728.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -1536.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -1344.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -1152.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": -960.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": -768.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": -576.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": -384.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": -192.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 0.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 192.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 384.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": 576.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_15", + "type": "terrain", + "position": { + "x": 768.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_16", + "type": "terrain", + "position": { + "x": 960.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_17", + "type": "terrain", + "position": { + "x": 1280.0, + "y": 512.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_18", + "type": "terrain", + "position": { + "x": 1536.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_19", + "type": "terrain", + "position": { + "x": 1792.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_20", + "type": "terrain", + "position": { + "x": 2048.0, + "y": -256.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_21", + "type": "terrain", + "position": { + "x": 2304.0, + "y": -512.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_22", + "type": "terrain", + "position": { + "x": 2560.0, + "y": -768.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -1312.0, + "y": 255.0 + }, + "rotation": 0.0, + "stk_path": "res://stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "walk_to", + "target": { + "x": 1004.0, + "y": 600.0 + } + } + ] + }, + { + "file_id": "prop_1", + "type": "prop", + "position": { + "x": -1536.0, + "y": -768.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_2", + "type": "prop", + "position": { + "x": -1056.0, + "y": -1440.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_3", + "type": "prop", + "position": { + "x": -448.0, + "y": -1632.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_4", + "type": "prop", + "position": { + "x": -128.0, + "y": -1280.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_5", + "type": "prop", + "position": { + "x": 512.0, + "y": -1696.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_6", + "type": "prop", + "position": { + "x": 864.0, + "y": -1440.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_7", + "type": "prop", + "position": { + "x": 1248.0, + "y": -1728.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_8", + "type": "prop", + "position": { + "x": 1632.0, + "y": -1408.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + } + ], + "event_rules": [ + { + "id": 0, + "trigger": { + "type": "collided", + "source_id": "stickman_1", + "target_id": "prop_3", + "params": {} + }, + "actions": [ + { + "type": "speak", + "target_id": "stickman_1", + "params": { + "text": "Ouch Man!", + "duration": 2.0, + "stop_current": false + } + } + ] + } + ], + "metadata": { + "created_unix": 1789411529, + "modified_unix": 1789411843.001, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/game_scenes/test_scene3.scene b/game_scenes/test_scene3.scene new file mode 100644 index 0000000..42f47b3 --- /dev/null +++ b/game_scenes/test_scene3.scene @@ -0,0 +1,489 @@ +{ + "version": "1.0", + "scene_name": "Basic Scene", + "camera": { + "position": { + "x": 0.0, + "y": -400.0 + }, + "zoom": 0.5 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -1920.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -1728.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -1536.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -1344.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -1152.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": -960.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": -768.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": -576.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": -384.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": -192.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 0.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": 192.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": 384.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": 576.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_15", + "type": "terrain", + "position": { + "x": 768.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_16", + "type": "terrain", + "position": { + "x": 960.0, + "y": 640.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_17", + "type": "terrain", + "position": { + "x": 1280.0, + "y": 512.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_18", + "type": "terrain", + "position": { + "x": 1536.0, + "y": 256.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_19", + "type": "terrain", + "position": { + "x": 1792.0, + "y": 0.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_20", + "type": "terrain", + "position": { + "x": 2048.0, + "y": -256.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_21", + "type": "terrain", + "position": { + "x": 2304.0, + "y": -512.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_22", + "type": "terrain", + "position": { + "x": 2560.0, + "y": -768.0 + }, + "rotation": 0.0, + "asset_id": "step", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -1312.0, + "y": 255.0 + }, + "rotation": 0.0, + "stk_path": "res://stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "walk_to", + "target": { + "x": 1004.0, + "y": 600.0 + } + } + ] + }, + { + "file_id": "prop_1", + "type": "prop", + "position": { + "x": -1536.0, + "y": -768.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_2", + "type": "prop", + "position": { + "x": -928.0, + "y": -1120.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_3", + "type": "prop", + "position": { + "x": -1184.0, + "y": -512.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_4", + "type": "prop", + "position": { + "x": -128.0, + "y": -1280.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_5", + "type": "prop", + "position": { + "x": 512.0, + "y": -1696.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_6", + "type": "prop", + "position": { + "x": 864.0, + "y": -1440.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_7", + "type": "prop", + "position": { + "x": 1248.0, + "y": -1728.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + }, + { + "file_id": "prop_8", + "type": "prop", + "position": { + "x": 1632.0, + "y": -1408.0 + }, + "rotation": 0.0, + "asset_id": "ball", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 2 + } + ], + "event_rules": [ + { + "id": 0, + "trigger": { + "type": "collided", + "source_id": "stickman_1", + "target_id": "prop_3", + "params": {} + }, + "actions": [ + { + "type": "speak", + "target_id": "stickman_1", + "params": { + "text": "Ouch Man!", + "duration": 2.0, + "stop_current": false + } + } + ] + } + ], + "metadata": { + "created_unix": 1789411529, + "modified_unix": 1789412438.352, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/plans/PHASE_5_FILE_FEATURES.md b/plans/PHASE_5_FILE_FEATURES.md new file mode 100644 index 0000000..5888405 --- /dev/null +++ b/plans/PHASE_5_FILE_FEATURES.md @@ -0,0 +1,506 @@ +# Phase 5 — Sandbox Scene File Features + +Status: implemented and verified in Godot/headless Phase 5 coverage. + +## 1. Goal and boundaries + +Phase 5 gives the standalone Sandbox Stage Builder durable, versioned scene files. It adds New, Open, Open Recent, Save, Save As, Rename Scene, dirty-state prompts, an autosave-on-Play recovery path, and tolerant handling of missing `.stk` assets. + +This plan targets `res://scenes/sandbox_stage.tscn`, which is run separately with F6. It does not change the stickman editor's existing `.stk` Save/Load flow or the project's main scene. + +Current architecture facts that constrain the design: + +- `SandboxStage` builds its entire UI in `scripts/sandbox_stage.gd`; the `.tscn` contains only `SandboxStage/Camera2D` and `SandboxStage/World`. +- There is no existing sandbox File menu or Clear command. Phase 5 creates the menu; it does not reuse a Clear flow. +- All authored objects are direct children of `World`. `NavigationRegion2D`, gizmos, overlays, ragdoll bodies, and collision proxies are not scene-file objects. +- `_authored` is the source of the edit pose restored after Play. Saving must use that authored transform rather than a prop's or rig's transient physics transform. Currently `_on_transform_committed()` refreshes `_authored` only for `RigidBody2D`; Phase 5 must move `_save_object_state(node)` outside that type branch so transformed terrain, areas, and stickmen are also authoritative. +- Runtime rules store `Object.get_instance_id()` integers in `trigger.source`, `trigger.target`, and rule-action `target`. Those IDs cannot be written as persistent references. +- Queue and rule dictionaries contain `Vector2` values. They require an explicit JSON conversion; raw runtime dictionaries are not the on-disk contract. +- `TerrainBlock.spawn_id` records its source template. `PropBlock` does not currently record its prop-library ID, and `StickmanRig` does not record its source `.stk` path. Phase 5 must add that provenance at spawn time. + +## 2. Files and classes + +| File | Planned responsibility | +| --- | --- | +| `scripts/stage_scene_io.gd` (new `class_name StageSceneIO`, `extends RefCounted`) | Own the v1.0 JSON contract, JSON-safe action/rule conversion, validation, migration dispatch, and failure-safe disk reads/writes. It does not mutate the live tree. | +| `scripts/sandbox_stage.gd` | Own file-menu/dialog flow, dirty state, pending destructive actions, live-tree collection/application, runtime-ID mapping, recents, autosave lifecycle, title/status updates, and one final navigation rebuild. | +| `scripts/stage_spawner.gd` | Record asset provenance and expose load-safe spawn methods whose positions are actual object origins, not placement-cursor coordinates. | +| `scripts/prop_block.gd` | Add `var spawn_id: String = ""`. | +| `scripts/stickman_rig.gd` | Add `var source_stk_path: String = ""`. This is provenance only and does not change runtime rig behavior. | +| `scripts/missing_stickman_placeholder.gd` (new `MissingStickmanPlaceholder`, `extends Node2D`) | Draw and expose selection bounds for a missing-stickman marker while retaining the original stickman file entry. | +| `tests/test_phase5_file_features.gd` (new) | Headless codec, round-trip, mapping, dirty-state, missing-asset, and autosave lifecycle coverage. | + +No authored node changes are required in `scenes/sandbox_stage.tscn`; the new controls remain code-built to match the current stage. + +## 3. Versioned `.scene` contract + +The extension is `.scene`. The content is UTF-8, tab-indented JSON. v1.0 uses only JSON primitives, arrays, and dictionaries. + +### 3.1 Root schema + +```json +{ + "version": "1.0", + "scene_name": "Playground", + "camera": { + "position": { "x": 0.0, "y": -400.0 }, + "zoom": 0.5 + }, + "objects": [], + "event_rules": [], + "metadata": { + "created_unix": 1789420000, + "modified_unix": 1789420300, + "autosave": false, + "source_path": "" + } +} +``` + +- `version` is required and must equal a supported version after migration. +- `scene_name` is a non-empty string after trimming; invalid or empty input becomes `"Untitled"`. +- `camera` is optional. Missing/invalid values use position `(0, -400)` and uniform zoom `0.5`; loaded zoom is clamped to the stage's `min_zoom`/`max_zoom`. +- `objects` and `event_rules` are required arrays. Unknown root and entry keys are ignored and are not preserved on the next save. +- `created_unix` is retained across manual saves. `modified_unix` is refreshed for each written snapshot. Both are UTC Unix seconds. +- Manual files use `autosave: false` and an empty `source_path`. An autosave uses `autosave: true` and stores the manual scene path, if any, in `source_path`. + +All positions are stage-world coordinates. All rotations are degrees in the file and radians at runtime. All vectors use `{ "x": number, "y": number }`. + +### 3.2 Object IDs and ordering + +Each entry has a required, non-empty `file_id` string. IDs must be unique across the whole `objects` array; a duplicate entry is rejected before anything is spawned or inserted into the reference map. On each save, objects are walked in current `World` child order and assigned readable IDs with per-type counters: `terrain_1`, `prop_1`, `stickman_1`, `area_1`. IDs are stable within that snapshot, not permanent node identities. + +The encoder simultaneously builds `runtime instance ID -> file_id`. The loader spawns objects in saved array order and builds `file_id -> runtime instance ID`. Navigation does not require terrain-first spawning, so preserving file order avoids an unnecessary World-order change; navigation is rebuilt once after all objects and rules are applied. + +### 3.3 Object entries + +Terrain: + +```json +{ + "file_id": "terrain_1", + "type": "terrain", + "asset_id": "ground", + "position": { "x": 0.0, "y": 0.0 }, + "rotation": 0.0, + "scale": { "x": 1.0, "y": 1.0 } +} +``` + +Prop: + +```json +{ + "file_id": "prop_1", + "type": "prop", + "asset_id": "crate", + "position": { "x": 100.0, "y": -40.0 }, + "rotation": 12.0, + "scale": { "x": 1.0, "y": 1.0 }, + "material_preset": "WOOD" +} +``` + +Stickman: + +```json +{ + "file_id": "stickman_1", + "type": "stickman", + "stk_path": "res://stickmen/test.stk", + "position": { "x": 0.0, "y": -385.0 }, + "rotation": 0.0, + "facing_profile": "FORWARD", + "action_queue": [ + { "type": "walk_to", "target": { "x": 300.0, "y": 0.0 } } + ] +} +``` + +Trigger area: + +```json +{ + "file_id": "area_1", + "type": "area", + "position": { "x": 500.0, "y": 0.0 }, + "rotation": 0.0, + "scale": { "x": 1.0, "y": 1.0 }, + "size": { "x": 160.0, "y": 80.0 } +} +``` + +Rules: + +- Terrain `asset_id` comes from `TerrainBlock.spawn_id`. +- Prop `asset_id` comes from the new `PropBlock.spawn_id`. `material_preset` is saved as the stable enum name `"NONE"`, `"WOOD"`, `"RUBBER"`, `"CARDBOARD"`, or `"METAL"` rather than its reorderable integer, even though current library templates also provide it. +- `stk_path` is the exact normalized source used to spawn the rig: keep `res://` paths project-relative and keep external paths absolute. Scene files do not embed `.stk` data in Phase 5. +- Stickman `position`, `rotation`, and `facing_profile` come from `_authored` when available, not the live Play pose. Root scale is intentionally not stored for stickmen because `StickmanRig.set_facing_profile(LEFT)` derives the root X mirror. Reapplying a serialized mirror would double-flip the rig. +- Terrain, prop, and area scale are saved. The present gizmo does not edit scale, but transformed areas already exist in tests and must round-trip correctly. +- Only known stage object classes are serialized. Ghosts, overlays, ragdoll bodies, navigation nodes, collision proxies, and unknown World children are skipped with a warning. + +### 3.4 Queue-action schema + +Queue actions preserve their existing flat runtime shape, with vectors converted to JSON dictionaries: + +```text +walk_to -> { "type": "walk_to", "target": {x, y} } +speak -> { "type": "speak", "text": String, "duration": float, optional "stop_current": bool } +wait -> { "type": "wait", "duration": float } +ragdoll -> { "type": "ragdoll" } +recover -> { "type": "recover" } +``` + +Order is preserved. `StageSceneIO` validates types against `ActionRegistry`; malformed or unknown actions are skipped with warnings instead of reaching the runner. + +### 3.5 Event-rule schema and reference boundary + +The disk rule deliberately differs from the live rule at reference fields: + +```json +{ + "id": 4, + "trigger": { + "type": "entered_area", + "source_id": "stickman_1", + "target_id": "area_1", + "params": {} + }, + "actions": [ + { + "type": "speak", + "target_id": "stickman_2", + "params": { "text": "Hello", "duration": 2.0 } + } + ] +} +``` + +- `source_id`/`target_id` are file-ID strings. A runtime `-1` wildcard is encoded as JSON `null` and decoded back to `-1`. +- Trigger params are: `waypoint_pos: {x,y}` for `arrived_at_waypoint`, `action_type: String` for `action_finished`, and `{}` for the other current trigger types. +- Rule-action params keep the existing nested runtime shape; `walk_to.params.target` is converted to/from `{x,y}`. +- On load, a rule is installed only when every non-null object reference resolves to a compatible live object. One unresolved reference skips the complete rule and contributes to the final load warning. +- Runtime rules remain unchanged: after decoding they again use integer instance IDs under `trigger.source`, `trigger.target`, and action `target`. Existing event matching, panels, and editors therefore need no data-model rewrite. +- Loaded numeric rule IDs are retained when unique and non-negative. Duplicate/invalid IDs are reassigned in array order. `_next_rule_id` becomes `max(loaded IDs) + 1`. + +This conversion boundary replaces the original assumption that the runtime rule dictionary can be serialized unchanged. + +### 3.6 Validation and migration + +`StageSceneIO` validates the complete root before the live stage is cleared. A missing file, I/O failure, invalid JSON, non-dictionary root, absent/unsupported version, or non-array `objects`/`event_rules` fails the load and leaves the current stage untouched. + +Individual invalid object/action/rule entries are warn-and-skip. Every decoded numeric field must be finite (`is_finite()`); vectors require finite numeric `x` and `y`, rotations/durations/zoom reject NaN or infinity, and area size must be positive. Unknown keys are dropped. v1.0 is the first version, so `_migrate_to_current(data: Dictionary) -> Dictionary` has no transformations yet, but version dispatch must exist and reject newer unsupported versions cleanly. + +## 4. `StageSceneIO` API contracts + +```gdscript +class_name StageSceneIO +extends RefCounted + +const FILE_VERSION: String = "1.0" + +static func read_file(path: String) -> Dictionary +# { "ok": bool, "data": Dictionary, "error": String, "warnings": Array[String] } + +static func write_file_safe(path: String, data: Dictionary) -> Dictionary +# { "ok": bool, "error": String } + +static func encode_queue(actions: Array[Dictionary]) -> Dictionary +# { "actions": Array[Dictionary], "warnings": Array[String] } + +static func decode_queue(entries: Array) -> Dictionary +# { "actions": Array[Dictionary], "warnings": Array[String] } + +static func encode_rules(rules: Array[Dictionary], runtime_to_file: Dictionary) -> Dictionary +# { "rules": Array[Dictionary], "warnings": Array[String] } + +static func decode_rules(entries: Array, file_to_runtime: Dictionary, unavailable_ids: Dictionary) -> Dictionary +# { "rules": Array[Dictionary], "warnings": Array[String], "skipped": int } + +static func vector_to_data(value: Vector2) -> Dictionary +static func is_vector_data(value: Variant) -> bool +static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2 +``` + +`write_file_safe` uses sibling `.tmp` and `.bak` files because Godot's `DirAccess.rename_absolute(temp, target)` is not a guaranteed overwrite operation when `target` already exists on Windows: + +1. Resolve and verify the absolute target/temp/backup paths share the same parent directory. +2. Write, flush, and close the complete temporary file. +3. If a target exists, remove only a stale verified sibling backup, then rename the target to the backup. +4. Rename the temporary file to the target. +5. If step 4 fails, rename the backup back to the target and report failure; never report success until the new target exists. +6. On success, remove the backup. On startup/read, if the target is absent but its backup exists, restore the backup before reading; if both target and backup exist, keep the target and remove the stale backup. Stale temporary files are safe to remove. + +Each same-directory rename is atomic at the filesystem level; the backup and rollback protocol protects the prior destination across the multi-step replacement and makes an interrupted swap recoverable. No recursive filesystem operation is involved. + +## 5. Spawn and provenance contracts + +`StageSpawner.spawn(id, cursor_position)` remains the palette API, including the stickman's `STICKMAN_FOOT_OFFSET`. Add load-safe APIs that do not apply a second placement offset: + +```gdscript +func spawn_terrain_asset(asset_id: String, object_position: Vector2) -> TerrainBlock +func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock +func spawn_stickman_asset(stk_path: String, root_position: Vector2) -> StickmanRig +func spawn_area(object_position: Vector2) -> TriggerArea +``` + +The existing palette helpers delegate to these methods. On success: + +- terrain sets `TerrainBlock.spawn_id = asset_id`; +- prop sets `PropBlock.spawn_id = asset_id`; +- stickman sets `StickmanRig.source_stk_path = normalized stk_path`. + +Unknown terrain/prop IDs return `null` plus a warning. A missing or malformed `.stk` returns `null` and is handled by the stage as §11 describes. Load APIs must not mutate `selected_prop_id` or `selected_stickman_path`. + +## 6. Sandbox stage state and internal APIs + +Add: + +```gdscript +enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT } + +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 + +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 = "" +``` + +Primary stage methods: + +```gdscript +func _mark_scene_dirty() -> void +func _set_scene_dirty(value: bool) -> void +func _collect_scene_data(is_autosave: bool = false) -> Dictionary +func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool +func _load_scene_from_path(path: String, is_recovery: bool = false) -> bool +func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) -> bool +func _reset_scene_content() -> void +func _request_pending_file_action(action: PendingFileAction, path: String = "") -> void +func _continue_pending_file_action() -> void +func _update_scene_chrome() -> void +func _update_recent_menu() -> void +``` + +`_suppress_dirty` is set with a scoped save/restore around load/New operations. It prevents spawn wiring and queue population from marking the new document dirty. It must be restored on every return path. + +## 7. File menu, dialogs, and layout + +Add a `MenuButton` as the first control in the existing code-built top-bar `HBoxContainer`. Give new dynamic controls explicit names so tests and future code can resolve them: + +```text +SandboxStage/UI (CanvasLayer) +├── TopBar (PanelContainer) +│ └── Content (HBoxContainer) +│ └── FileMenu (MenuButton) +├── SceneOpenDialog (FileDialog) +├── SceneSaveDialog (FileDialog) +├── RenameSceneDialog (ConfirmationDialog) +│ └── SceneNameEdit (LineEdit) +├── MissingAssetsDialog (AcceptDialog) +├── UnsavedChangesDialog (ConfirmationDialog) +└── AutosaveRecoveryDialog (ConfirmationDialog) +``` + +The existing asset `BrowseDialog` remains dedicated to `.stk`. The existing generic `_confirm_dialog` remains dedicated to delete/clear confirmations; file flow uses separate dialogs and callbacks. + +File menu items: + +| Item | Contract | +| --- | --- | +| New | Dirty-check, then reset content/name/path/camera. | +| Open… | Dirty-check, then show `SceneOpenDialog`. | +| Open Recent | Submenu, most recent first; missing paths are pruned when rebuilt. | +| Save | Save to `_current_scene_path`, or open Save As when empty. | +| Save As… | Open `SceneSaveDialog`; append `.scene` if the selected path has another/no extension. | +| Rename Scene… | Opens a wide `ConfirmationDialog` with Cancel, pre-fills `SceneNameEdit`, focuses it, and selects all text. The `LineEdit` uses the placeholder `Scene name`, which is visible only while the field is empty. A changed, non-empty confirmed name marks dirty. | + +There is no separate Clear item: the live sandbox has no existing Clear command, and New already supplies the destructive blank-stage behavior. + +The Open and Save dialogs use `ACCESS_FILESYSTEM` and filter `*.scene ; Stickman Scene Files`. Keyboard accelerators may be added for Ctrl+N/O/S and Ctrl+Shift+S, but are not required for Phase 5 acceptance. + +`_update_scene_chrome()` sets: + +- window title: `"%s%s — %s — Stickman Studio" % [_scene_name, " *" if _dirty else "", scene_file]` through `get_window().title`, where `scene_file` is the current `.scene` basename or `Unsaved`; +- status prefix: `"%s | Objects: ..."`, retaining the existing selection, pending-action, rule-hint, toast, and cursor behavior. + +## 8. Dirty-state and unsaved-changes flow + +Dirty becomes true on: + +- successful object placement, terrain paint commit, transform commit, or deletion; +- any rig `queue_changed` signal; +- rule create/update/delete/clear/reorder; +- a committed scene rename. + +Camera pan/zoom, selection/hover, grid/snap settings, mode changes, Play physics, and opening/canceling an editor do not dirty the file. + +Replace the current rig queue connection to `StageDirectorVisuals.mark_dirty` with a stage handler that calls both `mark_dirty()` and `_mark_scene_dirty()`. + +Unsaved protection applies before New, Open, Open Recent, and window close. `UnsavedChangesDialog` exposes Save, Discard, and Cancel: + +- Save performs Save or Save As. The pending action continues only after a successful write. Canceling Save As or a write failure cancels the pending action. +- Discard hides the unsaved-changes dialog and continues the pending action without writing. New therefore clears the stage and closes the prompt in the same operation. +- Cancel/window-close clears the pending action and changes nothing. + +Only one pending action exists at a time. For close interception, set `get_tree().auto_accept_quit = false` in `_ready()` and handle `NOTIFICATION_WM_CLOSE_REQUEST`. A clean close, successful save-then-close, or explicit discard-then-close retires the autosave before `get_tree().quit()`. + +Dirty is cleared only by successful manual Save/Save As, successful ordinary load, and New. Autosave never changes `_current_scene_path`, recents, timestamps held for the manual document, or `_dirty`. + +## 9. New and load transaction + +`_reset_scene_content()` first returns to Edit semantics so queues/ragdolls/physics are stopped, then closes transient popups and capture flows, clears selection and authored physics targets, removes all direct `World` children, and clears: + +- `_authored`, `_event_rules`, `_next_rule_id`; +- `_area_overlap`, `_collision_pairs`, terrain occupancy, director visuals; +- selection/hover/gizmo targets and placement ghosts. + +New additionally sets `Untitled`, empty current path, a new creation timestamp, the default camera, clean state, then rebuilds the grid/navigation/status once. + +Ordinary load is transactional at the document boundary: + +1. Read, parse, root-validate, and migrate before touching the live stage. +2. If root validation fails, show one error and keep the current document unchanged. +3. Reset the live content under `_suppress_dirty`. +4. Spawn valid objects in saved array order. Apply transform/properties before calling the existing `_finalize_placed_node()` wiring. +5. Build `file_id -> runtime instance ID`; track missing-stickman IDs separately. +6. Decode/install only rules whose references resolve. +7. Restore camera, name, creation timestamp, authored transforms, `_next_rule_id`, grid occupancy, and director visuals. Clear any queued `_nav_dirty` request, then rebuild navigation exactly once. +8. Ordinary load adopts the normalized source path, clears dirty, adds it to recents, and updates title/status. + +Object-level warnings are summarized in one toast after load; details also go to `push_warning`. A partially valid document therefore loads without turning one bad entry into total data loss. + +## 10. Recent files and settings merge + +Extend the existing `user://sandbox_settings.json` rather than creating another settings file: + +```json +{ + "version": "1.0", + "grid_size": 15.0, + "snap_to_grid": false, + "show_grid": true, + "recent_scenes": ["C:/Scenes/playground.scene"] +} +``` + +`_load_settings()` type-checks the list, normalizes/deduplicates paths, removes missing files, and caps it at five. `_save_settings()` writes both existing grid fields and recent scenes so one settings change cannot erase the other feature's state. + +A successful manual open or save moves that path to the front. Autosave is never added. The submenu rebuild removes newly missing paths and displays one disabled `(No recent scenes)` row when empty. If a recent file disappears between menu rebuild and click, show `File not found: `, remove it, save settings, and leave the current document untouched. + +## 11. Missing or corrupt `.stk` files + +Add `MissingStickmanPlaceholder` with: + +```gdscript +var original_entry: Dictionary = {} +var missing_stk_path: String = "" + +func configure(entry: Dictionary) -> void +func get_area_rect() -> Rect2 +``` + +It draws a red rectangle labeled `Missing asset - ` at local origin. `get_area_rect()` makes it selectable by the current duck-typed stage bounds path. It is a normal World child for movement, rotation, and deletion, but it has no rig/action/event behavior. + +When a stickman asset is absent or fails JSON parsing: + +1. Log the detailed failure. +2. Spawn a placeholder at the saved authored transform. +3. Store a deep copy of the original stickman entry, including its `stk_path`, facing, and action queue. +4. Mark its `file_id` unavailable when decoding rules, so every rule that refers to that missing rig is skipped as a whole. +5. Show one end-of-load toast with missing-stickman and skipped-rule counts. +6. Show one `MissingAssetsDialog` listing each distinct missing asset filename and saved path, with a note that red markers identify their scene positions. + +Saving a placeholder serializes it back as a `type: "stickman"` entry using `original_entry`, updated with the placeholder's current file ID and authored transform. Thus the missing path and queue survive and can recover automatically if the `.stk` later returns. The placeholder is not serialized as a new fifth object type. Deleting the placeholder and saving removes that stickman entry. + +## 12. Autosave-on-Play and recovery lifecycle + +Before `set_mode(StageMode.PLAY)` changes mode or unfreezes physics, write the authored scene snapshot to `user://sandbox_autosave.scene`. Failure shows a toast and logs a warning but does not block Play. + +Autosave write rules: + +- use the same v1.0 schema and failure-safe writer; +- set `metadata.autosave = true`, `metadata.source_path = _current_scene_path`, and a fresh `modified_unix`; +- do not clear dirty, update recents, adopt the autosave path, or change the manual document's creation/modified bookkeeping. + +On `_ready()`, after UI construction and settings load, inspect the autosave. Offer recovery only when it is valid and either has no source path, its source is missing, or its `modified_unix` is newer than the source file's filesystem modification time. A stale/invalid autosave is removed with a warning. + +- Recover applies the snapshot without adding the autosave path to recents. It restores `metadata.source_path` as `_current_scene_path` only when non-empty, and sets `_dirty = true` so the recovered snapshot must be manually saved. Keep the autosave until that manual save/New/Open/discard/clean shutdown retires it, protecting a second crash before save. +- Decline deletes the autosave and keeps the new blank stage. +- A successful manual save retires the autosave. +- A normal confirmed shutdown retires it. A crash/forced process stop leaves it behind, which is what makes the next-launch prompt meaningful. + +This lifecycle prevents a normal Play followed by an orderly exit from prompting on every next launch. + +## 13. Signals and theme impact + +No new public gameplay signal is required. Existing stage signals keep their current payloads and behavior. Loading should not emit `object_placed` once per reconstructed entry; it applies a document snapshot under `_suppress_dirty` and refreshes dependents once at the end. + +The file controls use the existing stage font and popup-theme helpers. No new `sandbox_theme.json` key is required. Dialog buttons use Godot's standard dialog theme. + +## 14. Verification strategy + +Create `tests/test_phase5_file_features.gd` and run it headlessly with the project's established test form: + +```powershell +& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase5_file_features.gd" +``` + +Required automated coverage: + +1. Vector/action/rule codecs emit only JSON-safe types and round-trip every current action/trigger type. +2. Runtime instance IDs encode to file IDs and decode to new live instance IDs, including wildcard `null <-> -1`. +3. Terrain/prop/stickman/area provenance, authored position/rotation, non-rig scale, area size, named material/facing enums, queues, rule order, and World order survive save/load. +4. A LEFT-facing rig loads with one mirror only; its derived root scale is not serialized. +5. Save while/after Play uses `_authored`, not displaced physics state. +6. Parse/root-validation failure does not clear the existing World. +7. Empty/duplicate file IDs, unknown object IDs, non-finite numbers/vectors, and malformed entries skip safely; navigation is rebuilt once. +8. Missing/corrupt `.stk` creates a selectable red labeled placeholder, shows a load-error dialog listing the missing asset(s), skips dependent rules, and re-save preserves the original stickman entry and queue. +9. Place/transform/delete, queue edits, rule edits/reorder, and rename dirty the scene; camera/grid/selection/mode do not. Load-time reconstruction remains clean. +10. Rename focuses and selects all text, exposes Cancel, and uses an empty-field-only placeholder hint. Save-via-Save-As continuation and Save/Discard/Cancel pending-action paths behave correctly, including canceled/failed saves and a closed prompt after Discard. +11. Recents deduplicate, reorder, cap at five, prune missing paths, and never include autosave. +12. Autosave does not clear dirty or adopt a path; stale autosaves are pruned, recovery is dirty, decline/manual save/orderly quit retire the file. +13. Safe-write replacement, rollback, and interrupted-backup recovery leave the prior destination recoverable and report failures. + +After the focused suite passes, run every existing script under `tests/` using its documented headless command; report suite names/results rather than relying on the stale aggregate assertion counts in older plans. + +Manual Godot Editor verification: + +- exercise File menu and all dialogs, including title `scene name * — scene-file.scene — Stickman Studio`, rename focus/select-all/placeholder behavior, cancel paths, and keyboard/window close; +- create all four object types, actions, cross-object rules, transforms, save, close, reload, and Play; +- rename/move a referenced `.stk`, verify the load-error dialog lists its filename/path and the scene shows a red `Missing asset - ` rectangle, save, restore the `.stk`, and reload; +- force-stop after Play to verify recovery, then exit normally after Play to verify no false recovery prompt. + +## 15. Approval gate: decisions to accept before implementation + +The architect recommends approving these as a set: + +1. Keep `.scene` as the user-facing JSON extension and v1.0 as the first file version. +2. Use per-save readable file IDs and a strict runtime-ID/file-ID conversion boundary; never persist Godot instance IDs. +3. Add typed provenance fields to `PropBlock` and `StickmanRig`, plus load-safe `StageSpawner` APIs, instead of relying on mutable palette selections or ad-hoc node metadata. +4. Omit a redundant Clear command; New is the blank-stage destructive action. +5. Preserve missing stickmen as stickman entries through a selectable placeholder, but drop complete rules that reference an unavailable rig. +6. Keep one autosave snapshot, write it on Play, retain it only across unclean exits/recovery, and mark recovered work dirty. +7. Drop unknown keys on re-save and reject newer unsupported versions; do not attempt forward-compatible preservation in v1.0. +8. Preserve saved World order and rebuild navigation once after load rather than grouping terrain first. + +## 16. Out of scope + +- Undo/redo +- Scene thumbnails/library/browser/templates +- Periodic or rotating autosave history +- Bundling/embedding `.stk` contents into `.scene` +- Relinking a missing `.stk` from the placeholder UI +- Cloud sharing/collaboration +- Video/GIF export + +Implementation and focused headless validation are complete; the user confirmed the feature is verified and working, so this plan records the final behavior for handoff. diff --git a/scripts/missing_stickman_placeholder.gd b/scripts/missing_stickman_placeholder.gd new file mode 100644 index 0000000..ed00a5e --- /dev/null +++ b/scripts/missing_stickman_placeholder.gd @@ -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) diff --git a/scripts/missing_stickman_placeholder.gd.uid b/scripts/missing_stickman_placeholder.gd.uid new file mode 100644 index 0000000..5eb8c1a --- /dev/null +++ b/scripts/missing_stickman_placeholder.gd.uid @@ -0,0 +1 @@ +uid://cd0b16vgbqyag diff --git a/scripts/prop_block.gd b/scripts/prop_block.gd index d9ab2dc..532982d 100644 --- a/scripts/prop_block.gd +++ b/scripts/prop_block.gd @@ -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 # --------------------------------------------------------------------------- diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index d36d499..c68d69c 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -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: diff --git a/scripts/stage_scene_io.gd b/scripts/stage_scene_io.gd new file mode 100644 index 0000000..bcbfc23 --- /dev/null +++ b/scripts/stage_scene_io.gd @@ -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) diff --git a/scripts/stage_scene_io.gd.uid b/scripts/stage_scene_io.gd.uid new file mode 100644 index 0000000..511dee3 --- /dev/null +++ b/scripts/stage_scene_io.gd.uid @@ -0,0 +1 @@ +uid://bwmd6m4bdwm0m diff --git a/scripts/stage_spawner.gd b/scripts/stage_spawner.gd index b22a925..0c8984b 100644 --- a/scripts/stage_spawner.gd +++ b/scripts/stage_spawner.gd @@ -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: diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index dd2c633..a25f2f2 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -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 diff --git a/stickmen/bad.stk b/stickmen/bad.stk new file mode 100644 index 0000000..b8d54d3 --- /dev/null +++ b/stickmen/bad.stk @@ -0,0 +1,246 @@ +{ + "version": "0.0", + "stickman_name": "Basic Broken", + "body_parts": { + "head": { + "shape_type": "z4.rectangle", + "points": [ + { + "x": 224.0, + "y": 33.0 + }, + { + "x": 324.0, + "y": 33.0 + }, + { + "x": 324.0, + "y": 93.0 + }, + { + "x": 224.0, + "y": 93.0 + } + ], + "color": "#000000", + "position": { + "x": 203.0, + "y": 511.0 + } + }, + "torso": { + "shape_type": "", + "points": [], + "color": "#000000", + "position": { + "x": 0.0, + "y": 0.0 + } + }, + "left_upper_arm": { + "shape_type": "", + "points": [], + "color": "#000000", + "position": { + "x": 0.0, + "y": 0.0 + } + }, + "left_lower_arm": { + "shape_type": "", + "points": [], + "color": "#000000", + "position": { + "x": 0.0, + "y": 0.0 + } + }, + "right_upper_arm": { + "shape_type": "circle", + "points": [ + { + "x": 198.0, + "y": 25.0 + }, + { + "x": 205.803619384766, + "y": 25.7685890197754 + }, + { + "x": 213.307342529297, + "y": 28.0448188781738 + }, + { + "x": 220.222808837891, + "y": 31.7412147521973 + }, + { + "x": 226.284271240234, + "y": 36.7157287597656 + }, + { + "x": 231.2587890625, + "y": 42.7771911621094 + }, + { + "x": 234.955184936523, + "y": 49.6926651000977 + }, + { + "x": 237.231414794922, + "y": 57.1963882446289 + }, + { + "x": 238.0, + "y": 65.0 + }, + { + "x": 237.231414794922, + "y": 72.8036117553711 + }, + { + "x": 234.955184936523, + "y": 80.3073348999023 + }, + { + "x": 231.2587890625, + "y": 87.2228088378906 + }, + { + "x": 226.284271240234, + "y": 93.2842712402344 + }, + { + "x": 220.222808837891, + "y": 98.2587890625 + }, + { + "x": 213.307342529297, + "y": 101.955184936523 + }, + { + "x": 205.803619384766, + "y": 104.231414794922 + }, + { + "x": 198.0, + "y": 105.0 + }, + { + "x": 190.196380615234, + "y": 104.231414794922 + }, + { + "x": 182.692657470703, + "y": 101.955184936523 + }, + { + "x": 175.777191162109, + "y": 98.2587890625 + }, + { + "x": 169.715728759766, + "y": 93.2842712402344 + }, + { + "x": 164.7412109375, + "y": 87.2228088378906 + }, + { + "x": 161.044815063477, + "y": 80.3073348999023 + }, + { + "x": 158.768585205078, + "y": 72.8036117553711 + }, + { + "x": 158.0, + "y": 65.0 + }, + { + "x": 158.768585205078, + "y": 57.1963882446289 + }, + { + "x": 161.044815063477, + "y": 49.6926651000977 + }, + { + "x": 164.7412109375, + "y": 42.7771911621094 + }, + { + "x": 169.715728759766, + "y": 36.7157287597656 + }, + { + "x": 175.777191162109, + "y": 31.7412147521973 + }, + { + "x": 182.692657470703, + "y": 28.0448188781738 + }, + { + "x": 190.196380615234, + "y": 25.7685890197754 + } + ], + "color": "#000000", + "position": { + "x": 279.0, + "y": 453.0 + } + }, + "right_lower_arm": { + "shape_type": "", + "points": [], + "color": "#000000", + "position": { + "x": 0.0, + "y": 0.0 + } + }, + "left_upper_leg": { + "shape_type": "", + "points": [], + "color": "#000000", + "position": { + "x": 0.0, + "y": 0.0 + } + }, + "left_lower_leg": { + "shape_type": "", + "points": [], + "color": "#000000", + "position": { + "x": 0.0, + "y": 0.0 + } + }, + "right_upper_leg": { + "shape_type": "", + "points": [], + "color": "#000000", + "position": { + "x": 0.0, + "y": 0.0 + } + }, + "right_lower_leg": { + "shape_type": "", + "points": [], + "color": "#000000", + "position": { + "x": 0.0, + "y": 0.0 + } + } + }, + "metadata": { + "created_at": "2026-08-05T10:28:56", + "modified_at": "2026-08-05T10:28:56" + } +} \ No newline at end of file diff --git a/tests/test_phase5_file_features.gd b/tests/test_phase5_file_features.gd new file mode 100644 index 0000000..fdb5a5f --- /dev/null +++ b/tests/test_phase5_file_features.gd @@ -0,0 +1,241 @@ +extends SceneTree + +const IO := preload("res://scripts/stage_scene_io.gd") +const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") +const MISSING_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd") + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_vectors_and_queue() + _test_rules() + _test_safe_write() + _test_root_validation() + _test_wildcards_and_unresolved_rules() + await _test_stage_round_trip_and_file_state() + print("----------------------------------------") + if _failures == 0: + print("RESULT: ALL PASSED (%d assertions)" % _checks) + quit(0) + else: + print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) + quit(1) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if condition: + print("PASS: " + message) + else: + _failures += 1 + push_error("Phase 5 test failed: " + message) + + +func _test_vectors_and_queue() -> void: + var source := Vector2(12.5, -3.0) + var encoded := IO.vector_to_data(source) + _expect(IO.is_vector_data(encoded), "vector is JSON-safe") + _expect(IO.data_to_vector(encoded, Vector2.ZERO) == source, "vector round trip") + _expect(not IO.is_vector_data({"x": INF, "y": 0.0}), "non-finite vector is rejected") + var queue: Array[Dictionary] = [ + {"type": "walk_to", "target": source}, + {"type": "speak", "text": "Hi", "duration": 1.0}, + {"type": "wait", "duration": 0.5}, + {"type": "ragdoll"}, + {"type": "recover"}, + ] + var decoded: Dictionary = IO.decode_queue(IO.encode_queue(queue)["actions"]) + _expect((decoded["actions"] as Array).size() == queue.size(), "all queue action types round trip") + var bad_queue: Array[Dictionary] = [{"type": "wait", "duration": INF}, {"type": "unknown"}] + _expect((IO.encode_queue(bad_queue)["actions"] as Array).is_empty(), "invalid queue actions are skipped") + + +func _test_rules() -> void: + var rules: Array[Dictionary] = [{ + "id": 2, + "trigger": { + "type": "arrived_at_waypoint", + "source": 10, + "target": -1, + "params": {"waypoint_pos": Vector2(1.0, 2.0)}, + }, + "actions": [{ + "type": "speak", + "target": 11, + "params": {"text": "Hello", "duration": 2.0}, + }], + }] + var encoded: Dictionary = IO.encode_rules(rules, {10: "stickman_1", 11: "stickman_2"}) + var disk_trigger: Dictionary = (encoded["rules"] as Array)[0]["trigger"] + _expect(disk_trigger["source_id"] == "stickman_1" and disk_trigger["target_id"] == null, "rule references encode as file IDs and null wildcard") + var decoded: Dictionary = IO.decode_rules(encoded["rules"], {"stickman_1": 20, "stickman_2": 21}, {}) + var live_rule: Dictionary = (decoded["rules"] as Array)[0] + _expect(int((live_rule["trigger"] as Dictionary)["source"]) == 20, "rule reference decodes to new runtime ID") + _expect(int(((live_rule["actions"] as Array)[0] as Dictionary)["target"]) == 21, "rule action target decodes to new runtime ID") + + +func _test_safe_write() -> void: + var path := "user://phase5_file_features.scene" + var first := {"version": "1.0", "scene_name": "First", "objects": [], "event_rules": []} + var replacement := {"version": "1.0", "scene_name": "Replacement", "objects": [], "event_rules": []} + _expect(bool(IO.write_file_safe(path, first)["ok"]), "safe initial write") + _expect(bool(IO.write_file_safe(path, replacement)["ok"]), "safe replacement write") + var read: Dictionary = IO.read_file(path) + _expect(bool(read["ok"]), "safe read") + _expect(String((read["data"] as Dictionary)["scene_name"]) == "Replacement", "replacement content wins") + DirAccess.remove_absolute(path) + + +func _test_root_validation() -> void: + var path := "user://phase5_invalid.scene" + var duplicate := { + "version": "1.0", + "objects": [ + {"file_id": "x", "type": "terrain", "position": {"x": 0.0, "y": 0.0}}, + {"file_id": "x", "type": "prop", "position": {"x": 0.0, "y": 0.0}}, + ], + "event_rules": [], + } + _expect(bool(IO.write_file_safe(path, duplicate)["ok"]), "write duplicate-ID fixture") + _expect(not bool(IO.read_file(path)["ok"]), "duplicate IDs reject before apply") + var malformed_object := { + "version": "1.0", + "objects": [{"file_id": "a", "type": "terrain", "position": {"x": "bad", "y": 0.0}}], + "event_rules": [], + } + _expect(bool(IO.write_file_safe(path, malformed_object)["ok"]), "write malformed-object fixture") + _expect(bool(IO.read_file(path)["ok"]), "malformed individual object is deferred for warn-and-skip") + DirAccess.remove_absolute(path) + + +func _test_wildcards_and_unresolved_rules() -> void: + var rules: Array[Dictionary] = [ + {"id": 1, "trigger": {"type": "speech_finished", "source": -1, "target": -1, "params": {}}, "actions": [{"type": "recover", "target": 10, "params": {}}]}, + {"id": 1, "trigger": {"type": "speech_finished", "source": -1, "target": -1, "params": {}}, "actions": [{"type": "recover", "target": 10, "params": {}}]}, + ] + var encoded: Dictionary = IO.encode_rules(rules, {10: "stickman_1"}) + var decoded: Dictionary = IO.decode_rules(encoded["rules"], {"stickman_1": 99}, {}) + _expect((decoded["rules"] as Array).size() == 2, "duplicate loaded rule IDs are reassigned") + _expect(int((decoded["rules"] as Array)[0]["id"]) != int((decoded["rules"] as Array)[1]["id"]), "reassigned rule IDs are unique") + var missing: Dictionary = IO.decode_rules(encoded["rules"], {}, {"stickman_1": true}) + _expect(int(missing["skipped"]) == 2, "unavailable references skip whole rules") + + +func _test_stage_round_trip_and_file_state() -> void: + var path := "user://phase5_stage_roundtrip.scene" + var stage: Node2D = STAGE_SCENE.instantiate() + root.add_child(stage) + await process_frame + stage._reset_scene_content() + + var terrain: TerrainBlock = stage._spawner.spawn_terrain_asset("ground", Vector2(20.0, 30.0)) + terrain.rotation = deg_to_rad(12.0) + terrain.scale = Vector2(1.25, 0.75) + stage._finalize_placed_node(terrain) + var prop: PropBlock = stage._spawner.spawn_prop_asset("crate", Vector2(140.0, -20.0)) + prop.rotation = deg_to_rad(-18.0) + stage._finalize_placed_node(prop) + var area: TriggerArea = stage._spawner.spawn_area(Vector2(300.0, 40.0)) + area.size = Vector2(160.0, 80.0) + area.scale = Vector2(1.2, 0.8) + stage._finalize_placed_node(area) + var rig: StickmanRig = stage._spawner.spawn_stickman_asset("res://stickmen/test.stk", Vector2(-100.0, -385.0)) + rig.set_facing_profile(StickmanRig.FacingProfile.LEFT) + rig.queue_action({"type": "wait", "duration": 0.25}) + stage._finalize_placed_node(rig) + stage._event_rules.assign([{ + "id": 7, + "trigger": {"type": "entered_area", "source": rig.get_instance_id(), "target": area.get_instance_id(), "params": {}}, + "actions": [{"type": "speak", "target": rig.get_instance_id(), "params": {"text": "Loaded", "duration": 1.0}}], + }]) + stage._scene_name = "Round Trip" + stage._set_scene_dirty(true) + _expect(stage._save_scene_to_path(path), "stage document saves") + _expect(not stage._dirty and stage._current_scene_path == path, "manual save adopts path and clears dirty") + _expect(stage.get_window().title == "Round Trip — phase5_stage_roundtrip.scene — Stickman Studio", "title identifies scene name and active file") + stage._rename_scene_prompt() + await process_frame + _expect(stage._rename_scene_dialog is ConfirmationDialog and stage._rename_scene_dialog.get_cancel_button().visible, "Rename Scene has a Cancel button") + _expect(stage._scene_name_edit.has_focus() and stage._scene_name_edit.has_selection(), "Rename Scene focuses and selects the existing name") + _expect(stage._rename_scene_dialog.dialog_text.is_empty() and stage._scene_name_edit.placeholder_text == "Scene name", "Rename hint is shown only as an empty textbox placeholder") + stage._rename_scene_dialog.hide() + + var saved: Dictionary = IO.read_file(path)["data"] + _expect((saved["objects"] as Array).size() == 4, "all four object types serialize") + var prop_entry: Dictionary = (saved["objects"] as Array)[1] + _expect(prop_entry["asset_id"] == "crate" and (prop_entry["material_preset"] is int or prop_entry["material_preset"] is float), "prop provenance and numeric material serialize") + _expect(String((saved["objects"] as Array)[3]["facing_profile"]) == "LEFT", "facing serializes by name") + _expect(absf(float((saved["objects"] as Array)[3]["rotation"])) < 0.01, "LEFT mirror is removed from authored rotation") + _expect(stage._load_scene_from_path(path), "stage document loads") + _expect(stage._world.get_child_count() == 4, "load reconstructs all objects") + _expect(stage._world.get_child(0) is TerrainBlock and (stage._world.get_child(0) as TerrainBlock).spawn_id == "ground", "World order and terrain provenance survive") + var loaded_prop := stage._world.get_child(1) as PropBlock + _expect(loaded_prop != null and loaded_prop.spawn_id == "crate" and loaded_prop.global_position.is_equal_approx(Vector2(140.0, -20.0)), "prop transform and provenance survive") + var loaded_area := stage._world.get_child(2) as TriggerArea + _expect(loaded_area != null and loaded_area.size == Vector2(160.0, 80.0) and loaded_area.scale.is_equal_approx(Vector2(1.2, 0.8)), "area size and scale survive") + var loaded_rig := stage._world.get_child(3) as StickmanRig + _expect(loaded_rig != null and loaded_rig.source_stk_path == "res://stickmen/test.stk" and loaded_rig.get_facing_profile() == StickmanRig.FacingProfile.LEFT, "stickman provenance and facing survive") + _expect(absf((loaded_rig.global_transform * Transform2D.FLIP_X).get_rotation()) < 0.01, "LEFT-facing rig loads with one mirror and canonical rotation") + _expect(loaded_rig.get_queue().size() == 1 and stage._event_rules.size() == 1, "queue and rule survive") + var loaded_trigger: Dictionary = stage._event_rules[0]["trigger"] + _expect(int(loaded_trigger["source"]) == loaded_rig.get_instance_id() and int(loaded_trigger["target"]) == loaded_area.get_instance_id(), "loaded rules resolve to new runtime IDs") + stage._update_recent_menu() + var recent_popup := stage._file_menu.get_popup().get_node("OpenRecent") as PopupMenu + _expect(stage._recent_scenes[0] == path and recent_popup.get_item_text(0) == path, "successful load updates Open Recent") + stage._recent_scenes.append("user://missing_recent_phase5.scene") + stage._update_recent_menu() + _expect(not stage._recent_scenes.has("user://missing_recent_phase5.scene"), "Open Recent prunes missing files") + + stage._camera.position += Vector2(10.0, 5.0) + _expect(not stage._dirty, "camera movement does not dirty document") + stage._mark_scene_dirty() + _expect(stage._dirty and "Round Trip" in stage._status_label.text and "*" in stage.get_window().title, "dirty state updates status and title") + var object_count_before_cancel: int = stage._world.get_child_count() + stage._request_pending_file_action(stage.PendingFileAction.NEW_SCENE) + stage._unsaved_changes_dialog.canceled.emit() + stage._unsaved_changes_dialog.hide() + _expect(stage._pending_file_action == stage.PendingFileAction.NONE and stage._world.get_child_count() == object_count_before_cancel, "Cancel aborts a pending destructive action") + var adopted_path: String = stage._current_scene_path + _expect(stage._save_scene_to_path(stage.AUTOSAVE_PATH, true), "autosave snapshot writes") + _expect(stage._dirty and stage._current_scene_path == adopted_path, "autosave does not clear dirty or adopt autosave path") + + var missing_data := { + "version": "1.0", + "scene_name": "Missing", + "objects": [{ + "file_id": "stickman_1", + "type": "stickman", + "stk_path": "user://definitely_missing_phase5.stk", + "position": {"x": 25.0, "y": 50.0}, + "rotation": 0.0, + "facing_profile": "FORWARD", + "action_queue": [{"type": "wait", "duration": 1.0}], + }], + "event_rules": [], + "metadata": {}, + } + _expect(stage._apply_scene_data(missing_data, path, false), "missing-stickman document loads tolerantly") + var placeholder: Node = stage._world.get_child(0) + _expect(placeholder.get_script() == MISSING_PLACEHOLDER and "1 missing stickman" in stage._toast_text, "missing .stk creates a summarized placeholder") + _expect(placeholder.get_area_rect().size.x >= 200.0 and "definitely_missing_phase5.stk" in stage._missing_assets_dialog.dialog_text, "missing .stk gets a labeled red marker and load-error dialog") + stage._missing_assets_dialog.hide() + var placeholder_saved: Dictionary = stage._collect_scene_data() + _expect(String((placeholder_saved["objects"] as Array)[0]["stk_path"]) == "user://definitely_missing_phase5.stk", "placeholder re-save preserves missing path") + + stage._set_scene_dirty(true) + stage._request_pending_file_action(stage.PendingFileAction.NEW_SCENE) + _expect(stage._pending_file_action == stage.PendingFileAction.NEW_SCENE and stage._unsaved_changes_dialog.visible, "dirty New opens pending unsaved prompt") + stage._on_unsaved_discard() + _expect(stage._world.get_child_count() == 0 and not stage._dirty and stage._current_scene_path.is_empty() and not stage._unsaved_changes_dialog.visible, "Discard closes its prompt and resets the document") + + stage.queue_free() + await process_frame + DirAccess.remove_absolute(path) + if FileAccess.file_exists("user://sandbox_autosave.scene"): + DirAccess.remove_absolute("user://sandbox_autosave.scene") diff --git a/tests/test_phase5_file_features.gd.uid b/tests/test_phase5_file_features.gd.uid new file mode 100644 index 0000000..74631c3 --- /dev/null +++ b/tests/test_phase5_file_features.gd.uid @@ -0,0 +1 @@ +uid://cucobm783c655