- 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.
507 lines
29 KiB
Markdown
507 lines
29 KiB
Markdown
# 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.
|