Add missing stickman placeholder and stage scene I/O functionality

- Implemented `MissingStickmanPlaceholder` class to visually indicate missing stickman assets in the scene.
- Created `StageSceneIO` class for handling JSON serialization and deserialization of stage documents, including encoding and decoding of actions and rules.
- Added tests for vector handling, rule encoding/decoding, safe file writing, root validation, and handling of missing assets.
- Introduced a new stickman asset file `bad.stk` for testing purposes.
- Enhanced test coverage for phase 5 file features, ensuring robust handling of scene data and missing assets.
This commit is contained in:
2026-09-14 15:45:32 -04:00
parent 3a1d522ec9
commit a7c58705e1
15 changed files with 3247 additions and 26 deletions
+501
View File
@@ -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": ""
}
}
+489
View File
@@ -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": ""
}
}
+489
View File
@@ -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": ""
}
}
+506
View File
@@ -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 `<target>.tmp` and `<target>.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: <path>`, 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 - <filename>` 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 - <filename>` 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.
+24
View File
@@ -0,0 +1,24 @@
class_name MissingStickmanPlaceholder
extends Node2D
var original_entry: Dictionary = {}
var missing_stk_path: String = ""
const MARKER_SIZE := Vector2(260.0, 52.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_stk_path = String(entry.get("stk_path", ""))
queue_redraw()
func get_area_rect() -> Rect2:
return Rect2(-MARKER_SIZE * 0.5, MARKER_SIZE)
func _draw() -> void:
var rect := get_area_rect()
draw_rect(rect, Color(0.76, 0.10, 0.10, 0.95), true)
draw_rect(rect, Color(1.0, 0.78, 0.78, 1.0), false, 2.0)
var asset_name := missing_stk_path.get_file()
if asset_name.is_empty():
asset_name = "Unnamed .stk asset"
draw_string(ThemeDB.fallback_font, rect.position + Vector2(10.0, 32.0), "Missing asset - %s" % asset_name, HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 20.0, 16, Color.WHITE)
@@ -0,0 +1 @@
uid://cd0b16vgbqyag
+3
View File
@@ -18,6 +18,9 @@ enum ShapeType { POLYGON, CIRCLE }
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL } enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
## Stable library provenance used by Sandbox Stage scene files.
var spawn_id: String = ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Child node names # Child node names
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+454 -3
View File
@@ -35,6 +35,8 @@ const RULE_PANEL := preload("res://scenes/rule_panel.tscn")
const ACTION_EDITOR := preload("res://scenes/action_editor.tscn") const ACTION_EDITOR := preload("res://scenes/action_editor.tscn")
const RULE_EDITOR := preload("res://scenes/rule_editor.tscn") const RULE_EDITOR := preload("res://scenes/rule_editor.tscn")
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd") 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 # 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). ## Phase 3c target-capture kinds (what the next stage click resolves to).
enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION } enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION }
enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Signals # Signals
@@ -72,6 +75,11 @@ const THEME_PATH := "res://sandbox_theme.json"
const DEFAULT_GRID_SIZE := 15.0 const DEFAULT_GRID_SIZE := 15.0
const MIN_GRID_SIZE := 1.0 const MIN_GRID_SIZE := 1.0
const MAX_GRID_SIZE := 100.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). ## Director action-popup item ids (Phase 3a).
const ACT_WALK := 0 const ACT_WALK := 0
@@ -175,6 +183,21 @@ var _grid_size_label: Label
var _grid_size: float = DEFAULT_GRID_SIZE var _grid_size: float = DEFAULT_GRID_SIZE
var _snap_enabled: bool = false var _snap_enabled: bool = false
var _show_grid: bool = true 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 _panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO var _pan_last: Vector2 = Vector2.ZERO
@@ -365,6 +388,7 @@ var _rule_editor_from_panel: bool = false
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _ready() -> void: func _ready() -> void:
get_tree().auto_accept_quit = false
_camera.make_current() _camera.make_current()
_load_theme() _load_theme()
_grid_size = _theme_grid_default _grid_size = _theme_grid_default
@@ -397,6 +421,8 @@ func _ready() -> void:
_refresh_mode_badge() _refresh_mode_badge()
_apply_cursor() _apply_cursor()
_refresh_status() _refresh_status()
_update_scene_chrome()
_check_autosave_recovery()
func _process(delta: float) -> void: func _process(delta: float) -> void:
@@ -581,6 +607,9 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
func set_mode(mode: StageMode) -> void: func set_mode(mode: StageMode) -> void:
if mode == current_mode: if mode == current_mode:
return return
if mode == StageMode.PLAY:
if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.")
current_mode = mode current_mode = mode
match mode: match mode:
StageMode.EDIT: StageMode.EDIT:
@@ -699,13 +728,20 @@ func _enter_play_mode() -> void:
func _save_object_state(node: Node2D) -> 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 := { var entry := {
"node": node, "node": node,
"position": node.global_position, "position": node.global_position,
"rotation": node.global_rotation, "rotation": authored_rotation,
} }
if node is STICKMAN_RIG: if node is STICKMAN_RIG:
entry["facing_profile"] = (node as STICKMAN_RIG).get_facing_profile() 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 _authored[node.get_instance_id()] = entry
@@ -757,6 +793,7 @@ func _place_at(world_pos: Vector2) -> void:
_nav_dirty = true _nav_dirty = true
_rebuild_grid_cells() _rebuild_grid_cells()
object_placed.emit(node) object_placed.emit(node)
_mark_scene_dirty()
_refresh_status() _refresh_status()
_spawn_ghost() _spawn_ghost()
@@ -771,7 +808,7 @@ func _finalize_placed_node(node: Node2D) -> void:
_save_object_state(node) _save_object_state(node)
if node is STICKMAN_RIG: if node is STICKMAN_RIG:
var rig := node as 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.arrived.connect(_on_rig_arrived.bind(rig))
rig.action_finished.connect(_on_rig_action_finished.bind(rig)) rig.action_finished.connect(_on_rig_action_finished.bind(rig))
rig.speech_finished.connect(_on_rig_speech_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(): if not placed.is_empty():
_nav_dirty = true _nav_dirty = true
_rebuild_grid_cells() _rebuild_grid_cells()
_mark_scene_dirty()
_end_terrain_drag() _end_terrain_drag()
_refresh_status() _refresh_status()
@@ -1150,12 +1188,276 @@ func delete_selected() -> void:
_nav_dirty = true _nav_dirty = true
_director_visuals.mark_dirty() _director_visuals.mark_dirty()
object_deleted.emit(selected) object_deleted.emit(selected)
_mark_scene_dirty()
_refresh_status() _refresh_status()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Status / UI # 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: func _refresh_status() -> void:
if _status_label == null: if _status_label == null:
return return
@@ -1168,7 +1470,7 @@ func _refresh_status() -> void:
sel_text = String(sel[0].name) sel_text = String(sel[0].name)
else: else:
sel_text = "%d objects" % sel.size() 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: if _pending_walk_target:
text += " | Click stage for walk target (Esc to cancel)" text += " | Click stage for walk target (Esc to cancel)"
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty(): if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
@@ -1333,9 +1635,28 @@ func _build_ui() -> void:
ui.add_child(top_bar) ui.add_child(top_bar)
var hbox := HBoxContainer.new() var hbox := HBoxContainer.new()
hbox.name = "Content"
hbox.add_theme_constant_override("separation", 8) hbox.add_theme_constant_override("separation", 8)
top_bar.add_child(hbox) 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. # 3-segment mode switcher.
var mode_defs: Array = [ var mode_defs: Array = [
[StageMode.EDIT, "✏️ Edit"], [StageMode.EDIT, "✏️ Edit"],
@@ -1549,6 +1870,50 @@ func _build_ui() -> void:
_browse_dialog.file_selected.connect(_on_browse_file_selected) _browse_dialog.file_selected.connect(_on_browse_file_selected)
ui.add_child(_browse_dialog) 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 ---- # ---- Phase 3c editor tools ----
_rig_context_popup = PopupMenu.new() _rig_context_popup = PopupMenu.new()
@@ -1867,6 +2232,77 @@ func _on_transform_committed(nodes: Array[Node2D]) -> void:
_rebuild_grid_cells() _rebuild_grid_cells()
_director_visuals.mark_dirty() _director_visuals.mark_dirty()
_edit_physics_settle_frames = 3 _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: func _on_gizmo_transform_started(_nodes: Array[Node2D]) -> void:
@@ -2419,6 +2855,7 @@ func _finalize_rule() -> void:
_event_rules.append(rule) _event_rules.append(rule)
_show_toast("Rule created!") _show_toast("Rule created!")
_director_visuals.set_rules(_event_rules) _director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_reset_rule_builder() _reset_rule_builder()
_restore_rule_build_panel() _restore_rule_build_panel()
_refresh_status() _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) _event_rules = _event_rules.filter(func(r): return int(r.get("id", -1)) != id)
if _event_rules.size() != before: if _event_rules.size() != before:
_director_visuals.set_rules(_event_rules) _director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_refresh_status() _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: _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)))) _event_rules = _event_rules.filter(func(r): return not ids.has(int(r.get("id", -1))))
_director_visuals.set_rules(_event_rules) _director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_refresh_rule_panel() _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: func _on_rule_panel_reorder_requested(ordered_ids: Array[int]) -> void:
_reorder_filtered_rules(ordered_ids) _reorder_filtered_rules(ordered_ids)
_director_visuals.set_rules(_event_rules) _director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
## Rewrites the filtered rules (whose ids are in ordered_ids) in the new order ## 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) _event_rules.append(rule)
_show_toast("Rule created!") _show_toast("Rule created!")
_director_visuals.set_rules(_event_rules) _director_visuals.set_rules(_event_rules)
_mark_scene_dirty()
_restore_rule_panel() _restore_rule_panel()
@@ -3496,6 +3937,15 @@ func _load_settings() -> void:
_grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE) _grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
_snap_enabled = bool(d.get("snap_to_grid", false)) _snap_enabled = bool(d.get("snap_to_grid", false))
_show_grid = bool(d.get("show_grid", true)) _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: func _save_settings() -> void:
@@ -3504,6 +3954,7 @@ func _save_settings() -> void:
"grid_size": _grid_size, "grid_size": _grid_size,
"snap_to_grid": _snap_enabled, "snap_to_grid": _snap_enabled,
"show_grid": _show_grid, "show_grid": _show_grid,
"recent_scenes": _recent_scenes,
} }
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file: if file:
+240
View File
@@ -0,0 +1,240 @@
class_name StageSceneIO
extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.0"
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
static func vector_to_data(value: Vector2) -> Dictionary:
return {"x": value.x, "y": value.y}
static func is_vector_data(value: Variant) -> bool:
if not value is Dictionary:
return false
var data: Dictionary = value
return _finite_number(data.get("x")) and _finite_number(data.get("y"))
static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
if not is_vector_data(value):
return fallback
var data: Dictionary = value
return Vector2(float(data["x"]), float(data["y"]))
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for action: Dictionary in actions:
var encoded := _encode_flat_action(action)
if encoded.is_empty():
warnings.append("Skipped invalid queue action.")
else:
out.append(encoded)
return {"actions": out, "warnings": warnings}
static func decode_queue(entries: Array) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for entry: Variant in entries:
if not entry is Dictionary:
warnings.append("Skipped malformed queue action.")
continue
var decoded := _decode_flat_action(entry as Dictionary)
if decoded.is_empty():
warnings.append("Skipped invalid queue action.")
else:
out.append(decoded)
return {"actions": out, "warnings": warnings}
static func encode_rules(rules: Array[Dictionary], runtime_to_file: Dictionary) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for rule: Dictionary in rules:
var trigger: Variant = rule.get("trigger", {})
if not trigger is Dictionary:
warnings.append("Skipped malformed rule.")
continue
var encoded_trigger := _encode_trigger(trigger as Dictionary, runtime_to_file)
if encoded_trigger.is_empty():
warnings.append("Skipped invalid rule trigger.")
continue
var encoded_actions: Array[Dictionary] = []
var valid := true
var actions: Variant = rule.get("actions", [])
if not actions is Array:
valid = false
else:
for action: Variant in actions:
if not action is Dictionary:
valid = false
break
var encoded := _encode_rule_action(action as Dictionary, runtime_to_file)
if encoded.is_empty():
valid = false
break
encoded_actions.append(encoded)
if not valid:
warnings.append("Skipped invalid rule action.")
continue
out.append({"id": int(rule.get("id", -1)), "trigger": encoded_trigger, "actions": encoded_actions})
return {"rules": out, "warnings": warnings}
static func decode_rules(entries: Array, file_to_runtime: Dictionary, unavailable_ids: Dictionary) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
var skipped := 0
var used_ids: Dictionary = {}
var next_id := 0
for entry: Variant in entries:
if not entry is Dictionary:
warnings.append("Skipped malformed rule."); skipped += 1; continue
var d: Dictionary = entry
var trigger_value: Variant = d.get("trigger", {})
if not trigger_value is Dictionary:
warnings.append("Skipped malformed rule."); skipped += 1; continue
var trigger := _decode_trigger(trigger_value as Dictionary, file_to_runtime, unavailable_ids)
if trigger.is_empty():
warnings.append("Skipped unresolved rule."); skipped += 1; continue
var decoded_actions: Array[Dictionary] = []
var valid := d.get("actions") is Array
if valid:
for action: Variant in d["actions"]:
if not action is Dictionary:
valid = false; break
var decoded := _decode_rule_action(action as Dictionary, file_to_runtime, unavailable_ids)
if decoded.is_empty():
valid = false; break
decoded_actions.append(decoded)
if not valid:
warnings.append("Skipped unresolved rule."); skipped += 1; continue
var id := int(d.get("id", -1))
if id < 0 or used_ids.has(id):
while used_ids.has(next_id): next_id += 1
id = next_id
used_ids[id] = true
out.append({"id": id, "trigger": trigger, "actions": decoded_actions})
return {"rules": out, "warnings": warnings, "skipped": skipped}
static func read_file(path: String) -> Dictionary:
_recover_backup(path)
if not FileAccess.file_exists(path): return {"ok": false, "data": {}, "error": "File not found: %s" % path, "warnings": []}
var file := FileAccess.open(path, FileAccess.READ)
if file == null: return {"ok": false, "data": {}, "error": "Could not open scene file.", "warnings": []}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary: return {"ok": false, "data": {}, "error": "Invalid scene JSON.", "warnings": []}
var data := _migrate_to_current(parsed as Dictionary)
if data.is_empty(): return {"ok": false, "data": {}, "error": "Unsupported or invalid scene version.", "warnings": []}
if not data.get("objects") is Array or not data.get("event_rules") is Array:
return {"ok": false, "data": {}, "error": "Scene is missing objects or event_rules arrays.", "warnings": []}
var validation: Dictionary = _validate_root(data)
if not bool(validation.get("ok", false)): return {"ok": false, "data": {}, "error": String(validation.get("error", "Invalid scene data.")), "warnings": []}
return {"ok": true, "data": data, "error": "", "warnings": []}
static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
var tmp := path + ".tmp"
var bak := path + ".bak"
var file := FileAccess.open(tmp, FileAccess.WRITE)
if file == null: return {"ok": false, "error": "Could not write temporary scene file."}
file.store_string(JSON.stringify(data, "\t", false))
file.flush(); file.close()
if FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
DirAccess.remove_absolute(tmp); return {"ok": false, "error": "Could not back up existing scene file."}
if DirAccess.rename_absolute(tmp, path) != OK:
if FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path)
return {"ok": false, "error": "Could not replace scene file."}
if FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
return {"ok": true, "error": ""}
static func _migrate_to_current(data: Dictionary) -> Dictionary:
if String(data.get("version", "")) != FILE_VERSION: return {}
return data
static func _validate_root(data: Dictionary) -> Dictionary:
var seen: Dictionary = {}
for raw: Variant in data["objects"]:
if not raw is Dictionary: continue
var entry: Dictionary = raw
var file_id := String(entry.get("file_id", ""))
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
seen[file_id] = true
return {"ok": true, "error": ""}
static func _recover_backup(path: String) -> void:
var bak := path + ".bak"
if not FileAccess.file_exists(path) and FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path)
elif FileAccess.file_exists(path) and FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
if FileAccess.file_exists(path + ".tmp"): DirAccess.remove_absolute(path + ".tmp")
static func _finite_number(value: Variant) -> bool:
return (value is float or value is int) and is_finite(float(value))
static func _encode_flat_action(action: Dictionary) -> Dictionary:
var type := String(action.get("type", ""))
if not ACTION_REGISTRY.has_type(type): return {}
match type:
"walk_to":
if not action.get("target") is Vector2: return {}
return {"type": type, "target": vector_to_data(action["target"])}
"speak": return {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false))} if _finite_number(action.get("duration", 2.0)) else {}
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
var type := String(action.get("type", ""))
if not ACTION_REGISTRY.has_type(type): return {}
match type:
"walk_to":
if not is_vector_data(action.get("target")): return {}
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO)}
"speak":
if not _finite_number(action.get("duration", 2.0)): return {}
var out := {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0))}
if bool(action.get("stop_current", false)): out["stop_current"] = true
return out
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
_: return {"type": type}
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
if int(value) == -1: return null
return map.get(int(value), null)
static func _unmap_ref(value: Variant, map: Dictionary, unavailable: Dictionary) -> int:
if value == null: return -1
if not value is String or unavailable.has(value) or not map.has(value): return -2
return int(map[value])
static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary:
var type := String(trigger.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs)
if source == null and int(trigger.get("source", -1)) != -1: return {}
if target == null and int(trigger.get("target", -1)) != -1: return {}
var params: Dictionary = trigger.get("params", {})
if type == "arrived_at_waypoint":
if not params.get("waypoint_pos") is Vector2: return {}
params = {"waypoint_pos": vector_to_data(params["waypoint_pos"])}
return {"type": type, "source_id": source, "target_id": target, "params": params}
static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
var type := String(value.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
var source := _unmap_ref(value.get("source_id"), refs, unavailable); var target := _unmap_ref(value.get("target_id"), refs, unavailable)
if source == -2 or target == -2: return {}
var params: Dictionary = value.get("params", {})
if type == "arrived_at_waypoint":
if not is_vector_data(params.get("waypoint_pos")): return {}
params = {"waypoint_pos": data_to_vector(params["waypoint_pos"], Vector2.ZERO)}
return {"type": type, "source": source, "target": target, "params": params}
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
var id: Variant = _map_ref(action.get("target", -1), refs); if id == null and int(action.get("target", -1)) != -1: return {}
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action)); if flat.is_empty(): return {}
var params := flat.duplicate(); params.erase("type")
return {"type": String(flat["type"]), "target_id": id, "params": params}
static func _decode_rule_action(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
var target := _unmap_ref(value.get("target_id"), refs, unavailable); if target == -2: return {}
var flat: Dictionary = value.get("params", {}).duplicate() if value.get("params") is Dictionary else {}
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat); if decoded.is_empty(): return {}
return ACTION_REGISTRY.to_rule_action(decoded, target)
+1
View File
@@ -0,0 +1 @@
uid://bwmd6m4bdwm0m
+48 -23
View File
@@ -127,18 +127,60 @@ func spawn(id: String, world_position: Vector2) -> Node2D:
var pos := world_position + offset var pos := world_position + offset
match String(entry.get("kind", "")): match String(entry.get("kind", "")):
"terrain": "terrain":
return _spawn_terrain(entry, pos) return spawn_terrain_asset(id, pos)
"prop": "prop":
return _spawn_prop(entry, pos) return spawn_prop_asset(selected_prop_id, pos)
"stickman": "stickman":
return _spawn_stickman(pos) return spawn_stickman_asset(selected_stickman_path, pos)
"area": "area":
return _spawn_area(pos) return spawn_area(pos)
_: _:
push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", "")) push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", ""))
return null 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 ## 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 ## points; for a stickman rig, union of its mounted `Body/*` shape geometry; a
## point rect otherwise. ## 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: func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
var t: Dictionary = PROP_LIBRARY.get_entry(selected_prop_id) return spawn_prop_asset(String(entry.get("id", selected_prop_id)), world_position)
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)
func _spawn_area(world_position: Vector2) -> Node2D: 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: func _spawn_stickman(world_position: Vector2) -> StickmanRig:
var data: Dictionary = _stickman_cache.get(selected_stickman_path, {}) return spawn_stickman_asset(selected_stickman_path, world_position)
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
static func _points_center(pts: PackedVector2Array) -> Vector2: static func _points_center(pts: PackedVector2Array) -> Vector2:
+3
View File
@@ -478,6 +478,9 @@ var _spawn_limb_rotations: Dictionary = {}
var action_queue: Array[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 _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1 var _current_index: int = -1
+246
View File
@@ -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"
}
}
+241
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://cucobm783c655