2 Commits
9 changed files with 580 additions and 7 deletions
+3 -2
View File
@@ -17,8 +17,9 @@ You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding stan
- **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations. - **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations.
- **Static Typing:** Enforce explicit type hints for all signatures and variables. - **Static Typing:** Enforce explicit type hints for all signatures and variables.
- **Decoupling:** Follow "call down, signal up". - **Decoupling:** Follow "call down, signal up".
- **Validation:** Run syntax verification command when finished: - **Validation:** Run syntax verification when finished (resolve `<godot>`/`<project>` per platform — see the "Command-line testing & debug playback" section of `AGENTS.md`):
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\stickman"` `<godot> --headless --check-only --script "res://path/to/script.gd" --path "<project>"`
- **Visual/debug verification:** when a runtime/visual bug needs observation, launch the Sandbox Stage with `--play --scene <path>` (add `--verbose` for richer logs) so you can watch it run and read `print()` output — see `AGENTS.md`.
## Output Format ## Output Format
+3 -2
View File
@@ -11,9 +11,10 @@ description: "Lean implementation loop: Architect -> Developer -> User Verificat
2. **Phase 2: Implementation & Engine Validation** 2. **Phase 2: Implementation & Engine Validation**
- `@developer` updates code and scenes using targeted diffs/snippets. - `@developer` updates code and scenes using targeted diffs/snippets.
- `@developer` runs headless syntax verification: - `@developer` runs headless syntax verification (resolve `<godot>`/`<project>` per platform — see the "Command-line testing & debug playback" section of `AGENTS.md`):
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\project"` `<godot> --headless --check-only --script "res://path/to/script.gd" --path "<project>"`
- If static check fails, `@developer` fixes the code immediately. - If static check fails, `@developer` fixes the code immediately.
- **Visual/debug verification (optional):** to actually watch a scene run and read `print()` debug output, launch the Sandbox Stage with `--play --scene <path>` from the command line (append `--verbose` for engine warnings) — see the "Command-line testing & debug playback" section of `AGENTS.md`. This is faster than relying on headless assertions alone for runtime/visual bugs.
3. **Phase 3: User Hand-off (Stop)** 3. **Phase 3: User Hand-off (Stop)**
- Present the changes to the user to test inside the Godot Editor. - Present the changes to the user to test inside the Godot Editor.
+16 -1
View File
@@ -2,9 +2,24 @@
## Project type ## Project type
- **Godot 4.4** 2D/GUI project (Forward Plus renderer) - **Godot 4.4** 2D/GUI project (Forward Plus renderer)
- No CLI build/test/lint commands; open in the Godot editor to run - Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`.
- **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`) - **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`)
## Command-line testing & debug playback
- **Godot binary (4.7.1) — resolve per platform.** Commands below use `<godot>` as shorthand:
- macOS: `/Applications/Godot.app/Contents/MacOS/Godot`
- Windows (PowerShell): `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe"`
- `<project>` = the repo root (e.g. `/Users/ryan/Projects/stickman`, or `C:\Godot4\stickman` on Windows).
- **Run a headless test suite** (`tests/*.gd` extend `SceneTree`, print PASS/FAIL, `quit(0/1)`):
`<godot> --headless --path <project> --script res://tests/test_cli_scene.gd`
- **Launch the Sandbox Stage and auto-PLAY a `.scene`** (visual/runtime debugging — `print()` output streams to the launching terminal):
`<godot> --path <project> res://scenes/sandbox_stage.tscn -- --play --scene <path>.scene`
- Args after `--` are parsed by `scripts/cli_scene_launcher.gd` (`CliSceneLauncher.parse`) via `OS.get_cmdline_user_args()`.
- `--scene <path>` (also `--scene=<path>` or a single bare positional) loads the scene; `--play` enters PLAY after a successful load.
- `--play` requires a scene — alone it shows the error dialog and stays blank/EDIT.
- A load failure shows a "Could Not Load Scene" dialog and falls back to a blank scene.
- **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines.
## Project overview ## Project overview
Stickman Studio is an editor tool for drawing and assembling stick figures. It is a Stickman Studio is an editor tool for drawing and assembling stick figures. It is a
`Control`-based GUI (not physics/animation) in its current phase. Body-part vector shapes `Control`-based GUI (not physics/animation) in its current phase. Body-part vector shapes
+296
View File
@@ -0,0 +1,296 @@
# Command-Line `.scene` Loader + PLAY-on-Launch
Status: proposed — awaiting approval before implementation.
## 1. Goal and boundaries
After Phase 5, a tester must be able to launch the standalone Sandbox Stage from the command line with a `.scene` file, have it load that scene, and (optionally) enter PLAY immediately so the scene "just plays" for automated/manual testing.
Four hard requirements:
1. A command-line `.scene` loader.
2. A flag to start "Stickman Studio" in PLAY mode.
3. PLAY mode is invalid unless a `.scene` is specified.
4. On scene-load error, show the user a dialog and default to a new (blank) scene.
This plan targets `res://scenes/sandbox_stage.tscn` (run standalone), its root script `scripts/sandbox_stage.gd`, a new pure parser, and a new headless test. It does **not** change the stickman editor, `main.tscn`, `project.godot`'s `run/main_scene`, or the `.scene` v1.0 JSON schema.
Constraints that shape the design:
- There is currently **no** command-line argument parsing anywhere in the repo.
- `SandboxStage._ready()` ends with `_update_scene_chrome()` then `_check_autosave_recovery()`. Startup integration must slot in there.
- `set_mode(StageMode.PLAY)` (line ~607) writes an autosave via `_save_scene_to_path(AUTOSAVE_PATH, true)` **before** `_enter_play_mode()`. A CLI PLAY transition must not create that autosave.
- `_load_scene_from_path(path, is_recovery=false)` surfaces read failures with a **toast**, not a dialog (line ~1284). The CLI path needs a dialog-bearing variant.
- `StageSceneIO.read_file(path)` returns `{ok, data, error, warnings}`; `error` is a `String`.
- `_show_missing_assets_dialog(paths)` (line ~1383) is the existing `AcceptDialog` pattern to reuse.
- The stage starts as a blank Untitled document (`_scene_name = "Untitled"`, `_current_scene_path = ""`, default camera), `current_mode` starts `StageMode.EDIT`.
- Tests live in `tests/*.gd`, extend `SceneTree`, instantiate `sandbox_stage.tscn`, and drive internals directly (see `tests/test_phase5_file_features.gd`). No test autoload exists.
## 2. Command-line contract
### 2.1 Flags
| Flag | Type | Meaning |
| --- | --- | --- |
| `--play` | boolean | After a successful `--scene` load, enter `StageMode.PLAY`. |
| `--scene <path>` | value (space-separated) | The `.scene` file to load. |
| `--scene=<path>` | value (equals form) | Same, `=` separated. |
A **bare positional** token that is not `--`-prefixed is accepted as a convenience scene path, but **only when `--scene` is absent**. Supplying both `--scene` and a positional path is a parse error (avoid silent ambiguity).
### 2.2 Godot separator mechanics
Godot consumes its own arguments up to a `--` separator; everything **after** `--` is exposed to the script via `OS.get_cmdline_user_args()`. To also run the sandbox stage instead of the project main scene, the stage scene is passed **positionally before** `--`:
```
Godot --path <project> res://scenes/sandbox_stage.tscn -- <user args>
```
`OS.get_cmdline_user_args()` then returns exactly `["--play", "--scene", "/abs/foo.scene"]` (the positional scene path is **not** in the user-args array — it is a Godot argument).
### 2.3 Exact launch commands (macOS binary per project facts)
Primary tester flow — open and PLAY:
```bash
/Applications/Godot.app/Contents/MacOS/Godot \
--path /Users/ryan/Projects/stickman \
res://scenes/sandbox_stage.tscn \
-- --play --scene /Users/ryan/Projects/stickman/stickmen/test.scene
```
Equivalent forms:
```bash
# --scene= form
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play --scene=res://my_playground.scene
# bare positional scene path (no --scene)
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play /Users/ryan/Projects/stickman/my_playground.scene
# load into EDIT (no PLAY)
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --scene res://my_playground.scene
```
Windows equivalent (same tokens, project's established console binary):
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --path "C:\Godot4\stickman" res://scenes/sandbox_stage.tscn -- --play --scene "C:\Godot4\stickman\stickmen\test.scene"
```
### 2.4 Path handling
- `res://…`, `user://…`, and absolute paths are passed through unchanged; `StageSceneIO.read_file` / `FileAccess` already handle all three (the stage already stores both `res://` and absolute paths in recents).
- The parser performs **no** scheme normalization. A bare relative path (no scheme, not absolute) is resolved by Godot against the process CWD and is therefore fragile; the documented and recommended forms are `res://` (project-relative) or absolute. This keeps the parser pure and testable.
## 3. Files and classes
| File | Change | Responsibility |
| --- | --- | --- |
| `scripts/cli_scene_launcher.gd` | **new** `class_name CliSceneLauncher`, `extends RefCounted` | Pure argument parser; no scene-tree or OS access. Returns a structured result. |
| `scripts/sandbox_stage.gd` | modified | Startup integration, the CLI load/error-dialog path, the autosave-suppress guard, and the new error dialog in `_build_ui()`. |
| `tests/test_cli_scene.gd` | **new** | Headless parser + startup-integration coverage. |
No changes to `StageSceneIO`, `stickman_editor.tscn`, `main.tscn`, `project.godot`, or the `.scene` v1.0 schema. No authored `.tscn` node changes in `sandbox_stage.tscn` (the error dialog is code-built like the other file dialogs).
### 3.1 `CliSceneLauncher` API contract
```gdscript
class_name CliSceneLauncher
extends RefCounted
static func parse(args: Array) -> Dictionary
# -> { "scene_path": String, "play": bool, "error": String }
# scene_path = "" when no scene requested
# play = false unless --play was present
# error = "" when valid, else a human-readable message
```
Parsing rules (documented so tests assert them):
1. Scan tokens left to right.
2. `--play` → `play = true`.
3. `--scene` → consume the next token as the scene path; missing/empty value → error.
4. `--scene=<path>` → scene path; empty value → error.
5. A non-`--`-prefixed token → positional scene path; a second positional → error.
6. Both `--scene` and a positional path → error (`"Specify the scene with either --scene or a single positional path."`).
7. Any other `--`-prefixed token → error (`"Unknown option: …"`).
8. After the scan: `play == true` with `scene_path == ""` → error (`"--play requires --scene <path>."`).
The parser enforces requirement 3 (PLAY requires a scene) so the stage can treat any `error` uniformly.
### 3.2 `sandbox_stage.gd` additions
```gdscript
var _cli_error_dialog: AcceptDialog = null # code-built in _build_ui()
var _cli_startup: bool = false # scoped suppress of autosave-on-Play
func _handle_cli_startup(args: Array) -> void # entry point from _ready()
func _load_cli_scene(path: String) -> Dictionary # -> { "ok": bool, "error": String }
func _show_cli_error_dialog(message: String) -> void
```
`_load_cli_scene` reuses `StageSceneIO.read_file` + `_apply_scene_data(data, path, false)` (the ordinary clean-load path), but returns the read error instead of toasting, so the caller can surface it in a dialog:
```gdscript
func _load_cli_scene(path: String) -> Dictionary:
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
if not bool(read.get("ok", false)):
return {"ok": false, "error": String(read.get("error", "Could not load scene."))}
if not _apply_scene_data(read["data"], path, false):
return {"ok": false, "error": "Could not apply scene data."}
return {"ok": true, "error": ""}
```
The existing `_load_scene_from_path` toast path is left untouched for interactive Open.
## 4. Startup flow integration into `_ready()`
Replace the final `_check_autosave_recovery()` line in `_ready()` with a single `_handle_cli_startup(OS.get_cmdline_user_args())` call (which internally owns recovery for the no-`--scene` case):
```gdscript
# _ready() tail (current -> new)
_update_scene_chrome()
_check_autosave_recovery() # removed from _ready() body
# becomes:
_update_scene_chrome()
_handle_cli_startup(OS.get_cmdline_user_args())
```
`_handle_cli_startup(args)` flow:
```text
result := CliSceneLauncher.parse(args)
if result.error != "":
_show_cli_error_dialog(result.error)
_check_autosave_recovery() # invalid CLI -> normal blank-session recovery
return # stay EDIT / blank
if result.scene_path.is_empty():
_check_autosave_recovery() # no --scene -> unchanged behavior
return
# Explicit --scene: retire autosave and skip recovery (explicit file intent wins).
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
load := _load_cli_scene(result.scene_path)
if not load.ok:
_show_cli_error_dialog(load.error)
return # blank/Untitled stage remains
if result.play:
_cli_startup = true # suppress the autosave-on-Play write
set_mode(StageMode.PLAY)
_cli_startup = false
```
### 4.1 Ordering with autosave recovery (decision)
When `--scene` is explicitly given, `_check_autosave_recovery()` is **skipped** and the autosave file is **retired (deleted)** before the CLI load. Justification:
- Explicit user intent ("open *this* file, and play it") must win over an offer to recover a previous interrupted session; popping the recovery dialog over a freshly CLI-loaded scene is both wrong and confusing.
- `_apply_scene_data(..., is_recovery=false)` already deletes the autosave on success, but the **failure** path never reaches that line; deleting upfront covers both outcomes so a stale autosave cannot prompt on a later launch.
- When no `--scene` is given (including the invalid `--play`-only case), recovery is unchanged: `_check_autosave_recovery()` still runs, preserving the existing crash-recovery lifecycle for ordinary launches.
### 4.2 `set_mode` autosave guard
```gdscript
# set_mode(), current:
if mode == StageMode.PLAY:
if not _save_scene_to_path(AUTOSAVE_PATH, true): ...
# becomes:
if mode == StageMode.PLAY and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true): ...
```
`_cli_startup` is a scoped boolean set only around the CLI `set_mode(StageMode.PLAY)` call. This prevents the CLI PLAY transition from writing `user://sandbox_autosave.scene` (which would otherwise be newer than its own source file and trigger a false recovery prompt on the next launch). It does not change the interactive Play button path, and it still routes the transition through the public `set_mode()` so mode bookkeeping (`_sync_mode_buttons`, `_apply_mode_frame`, `_refresh_mode_badge`, `mode_changed`) runs exactly as for a manual Play press.
## 5. Error-dialog and fallback-to-new-scene behavior
A new `AcceptDialog` `_cli_error_dialog` is built in `_build_ui()` alongside `_missing_assets_dialog`:
```text
SandboxStage/UI
└── CliErrorDialog (AcceptDialog)
title: "Could Not Load Scene"
name: "CliErrorDialog"
```
`_show_cli_error_dialog(message)` sets `dialog_text = message` and calls `popup_centered()`, mirroring `_show_missing_assets_dialog`. It is a **blocking** dialog (not the transient toast), satisfying requirement 4.
After the dialog is dismissed, the stage is left exactly as a brand-new document: `Untitled`, empty `World`, default camera, `current_mode == EDIT`. No PLAY is entered. This is the "default to a new (blank) scene" guarantee.
The error dialog is also used for the `--play`-without-`--scene` case (requirement 3), with the parser's error text (e.g. `"--play requires --scene <path>."`).
## 6. `--play` validity rules
| CLI input | Behavior |
| --- | --- |
| `--scene <valid>` | Load the scene, remain EDIT. Title bar shows the loaded scene. |
| `--play --scene <valid>` | Load the scene, then `set_mode(StageMode.PLAY)` (autosave suppressed). |
| `--play` (no `--scene`) | Parser error → `CliErrorDialog`, remain EDIT/blank. **Never** auto-enter PLAY. |
| `--scene <missing/corrupt>` | `CliErrorDialog` with the read error, remain EDIT/blank. |
| unknown flag / two positional paths / `--scene`+positional | Parser error → `CliErrorDialog`, remain EDIT/blank. |
## 7. Dirty / recent state
A CLI load uses the **normal clean-load** semantics (`_apply_scene_data(..., is_recovery=false)`): it adopts `_current_scene_path`, clears `_dirty`, adds the path to recents (`_add_recent_scene`), removes the autosave, and updates the title. This is what makes the title bar show `Scene Name — <file>.scene — Stickman Studio` and keeps the Open Recent menu consistent with the launched file. No special "read-only" or "dirty-on-load" CLI state is introduced.
## 8. Verification strategy
### 8.1 Headless test — `tests/test_cli_scene.gd`
Run with the project's established headless form (macOS):
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/ryan/Projects/stickman --script res://tests/test_cli_scene.gd
```
Required assertions:
1. **Parser (pure, no scene tree):** `CliSceneLauncher.parse([])` → `{scene_path:"", play:false, error:""}`; `["--play","--scene","res://x.scene"]` → `{scene_path:"res://x.scene", play:true, error:""}`; `["--scene=res://x.scene"]` → scene path set; bare positional `["/abs/x.scene","--play"]` → scene path + play; `["--play"]` → error; `["--scene"]` (missing value) → error; `["--bogus"]` → error; two positionals → error; `--scene` + positional → error.
2. **Load:** instantiate `sandbox_stage.tscn`, write a small valid `.scene` fixture to `user://`, call `_handle_cli_startup(["--scene", fixture])`, assert `_current_scene_path == fixture`, `World` child count matches, `_dirty == false`, `_recent_scenes[0] == fixture`, title contains the scene basename, and the autosave file is absent.
3. **PLAY:** `_handle_cli_startup(["--play","--scene", fixture])` → `current_mode == StageMode.PLAY` and `user://sandbox_autosave.scene` does **not** exist afterward (autosave-on-Play suppressed).
4. **Error → blank:** `_handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"])` → `_cli_error_dialog` visible with non-empty `dialog_text`, `World.get_child_count() == 0`, `current_mode == StageMode.EDIT`.
5. **`--play` without scene:** `_handle_cli_startup(["--play"])` → `_cli_error_dialog` visible, `current_mode == StageMode.EDIT`, never PLAY.
6. **Recovery skipped:** pre-write a valid `user://sandbox_autosave.scene` (autosave metadata), call `_handle_cli_startup(["--scene", fixture])`, assert the autosave file is removed and no `AutosaveRecoveryDialog` was created.
Note the repo gotcha (`docs/tech_debt_and_optimizations.md` #22): a newly added `class_name CliSceneLauncher` is not resolvable by `--check-only --script` until an `--editor` pass regenerates `.godot/global_script_class_cache.cfg`. Run once first:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --editor --path /Users/ryan/Projects/stickman --quit
```
Then a parse check:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --check-only --script res://scripts/cli_scene_launcher.gd --path /Users/ryan/Projects/stickman --quit
```
After the focused suite passes, re-run every existing script under `tests/` using its documented headless command and report suite names/results.
### 8.2 Manual Godot Editor verification
Run the exact launch commands from §2.3 in a terminal (not F6) and confirm:
- `--scene res://<file>.scene` opens the scene in EDIT with the correct title and Open Recent entry.
- `--play --scene res://<file>.scene` opens and immediately plays; the Play mode badge shows `▶️ SIMULATING`; no recovery prompt appears on the following ordinary launch.
- `--play` alone shows the "Could Not Load Scene" dialog with `"--play requires --scene <path>."`, leaves a blank Untitled stage, and does not enter PLAY.
- `--scene /nonexistent.scene` shows the dialog with the read error and leaves a blank Untitled stage.
- A pre-existing `user://sandbox_autosave.scene` is removed when launching with `--scene` (explicit file intent overrides recovery).
## 9. Approval gate: decisions to accept before implementation
The architect recommends approving these as a set:
1. **Flag syntax** — `--play` (boolean) and `--scene <path>` (with `--scene=<path>` and a single bare-positional convenience), parsed from `OS.get_cmdline_user_args()`; the parser enforces "PLAY requires a scene" as an error. Paths must be `res://`/`user://`/absolute (no scheme guessing).
2. **Autosave policy** — an explicit `--scene` skips `_check_autosave_recovery()` and retires the autosave file; the CLI PLAY transition suppresses the autosave-on-Play write via a scoped `_cli_startup` guard (leaving the interactive Play button untouched).
3. **Failure semantics** — on any CLI parse error or `--scene` load failure, show a blocking `AcceptDialog` (`CliErrorDialog`) and remain on a blank EDIT/Untitled stage; never auto-enter PLAY. A successful CLI load uses normal clean-load semantics (adopts path, clears dirty, updates recents/title).
## 10. Out of scope
- Changes to `main.tscn`, `stickman_editor.tscn`, `project.godot` `run/main_scene`, or the `.scene` v1.0 JSON schema.
- Reusing the CLI loader to launch the stickman **editor** (`.stk`) — this targets the Sandbox Stage only.
- `--headless`-only automation mode, output/logging flags, or a `--quit-after` flag.
- Argument path normalization (scheme guessing), environment-variable overrides, or multiple-scene batch loading.
+47
View File
@@ -0,0 +1,47 @@
class_name CliSceneLauncher
extends RefCounted
## Pure command-line argument parser for the Sandbox Stage scene loader.
## No scene-tree or OS access, so it is directly unit-testable. Parses
## OS.get_cmdline_user_args() output into a structured {scene_path, play, error}
## result; the caller (SandboxStage) is responsible for the error dialog and
## autosave policy.
static func parse(args: Array) -> Dictionary:
var scene_path := ""
var play := false
var used_scene_flag := false
var used_positional := false
var error := ""
var i := 0
while i < args.size():
var token := String(args[i])
if token == "--play":
play = true
elif token == "--scene":
used_scene_flag = true
if i + 1 >= args.size():
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
var value := String(args[i + 1])
if value.is_empty():
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
scene_path = value
i += 1
elif token.begins_with("--scene="):
used_scene_flag = true
var value := token.substr("--scene=".length())
if value.is_empty():
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
scene_path = value
elif token.begins_with("--"):
return {"scene_path": "", "play": play, "error": "Unknown option: %s" % token}
else:
if used_positional:
return {"scene_path": "", "play": play, "error": "Specify the scene with either --scene or a single positional path."}
used_positional = true
scene_path = token
i += 1
if used_scene_flag and used_positional:
error = "Specify the scene with either --scene or a single positional path."
elif play and scene_path.is_empty():
error = "--play requires --scene <path>."
return {"scene_path": scene_path, "play": play, "error": error}
+1
View File
@@ -0,0 +1 @@
uid://dmoyg7nq46v07
+47 -2
View File
@@ -198,6 +198,8 @@ var _unsaved_changes_dialog: ConfirmationDialog = null
var _rename_scene_dialog: ConfirmationDialog = null var _rename_scene_dialog: ConfirmationDialog = null
var _scene_name_edit: LineEdit = null var _scene_name_edit: LineEdit = null
var _missing_assets_dialog: AcceptDialog = null var _missing_assets_dialog: AcceptDialog = null
var _cli_error_dialog: AcceptDialog = null
var _cli_startup: bool = false
var _panning: bool = false var _panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO var _pan_last: Vector2 = Vector2.ZERO
@@ -423,7 +425,7 @@ func _ready() -> void:
_apply_cursor() _apply_cursor()
_refresh_status() _refresh_status()
_update_scene_chrome() _update_scene_chrome()
_check_autosave_recovery() _handle_cli_startup(OS.get_cmdline_user_args())
func _process(delta: float) -> void: func _process(delta: float) -> void:
@@ -608,7 +610,7 @@ 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 mode == StageMode.PLAY and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true): if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.") push_warning("SandboxStage: autosave before Play failed.")
current_mode = mode current_mode = mode
@@ -1459,6 +1461,44 @@ func _check_autosave_recovery() -> void:
get_node(NodePath("UI")).add_child(dialog); dialog.popup_centered() get_node(NodePath("UI")).add_child(dialog); dialog.popup_centered()
func _handle_cli_startup(args: Array) -> void:
var result: Dictionary = CliSceneLauncher.parse(args)
if not String(result.get("error", "")).is_empty():
_show_cli_error_dialog(String(result["error"]))
_check_autosave_recovery()
return
var scene_path := String(result.get("scene_path", ""))
if scene_path.is_empty():
_check_autosave_recovery()
return
if FileAccess.file_exists(AUTOSAVE_PATH):
DirAccess.remove_absolute(AUTOSAVE_PATH)
var load: Dictionary = _load_cli_scene(scene_path)
if not bool(load.get("ok", false)):
_show_cli_error_dialog(String(load.get("error", "Could not load scene.")))
return
if bool(result.get("play", false)):
_cli_startup = true
set_mode(StageMode.PLAY)
_cli_startup = false
func _load_cli_scene(path: String) -> Dictionary:
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
if not bool(read.get("ok", false)):
return {"ok": false, "error": String(read.get("error", "Could not load scene."))}
if not _apply_scene_data(read["data"], path, false):
return {"ok": false, "error": "Could not apply scene data."}
return {"ok": true, "error": ""}
func _show_cli_error_dialog(message: String) -> void:
if _cli_error_dialog == null:
return
_cli_error_dialog.dialog_text = message
_cli_error_dialog.popup_centered()
func _notification(what: int) -> void: func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST: if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_pending_file_action(PendingFileAction.QUIT) _request_pending_file_action(PendingFileAction.QUIT)
@@ -1911,6 +1951,11 @@ func _build_ui() -> void:
_missing_assets_dialog.title = "Missing Scene Assets" _missing_assets_dialog.title = "Missing Scene Assets"
_missing_assets_dialog.min_size = Vector2i(560, 0) _missing_assets_dialog.min_size = Vector2i(560, 0)
ui.add_child(_missing_assets_dialog) ui.add_child(_missing_assets_dialog)
_cli_error_dialog = AcceptDialog.new()
_cli_error_dialog.name = "CliErrorDialog"
_cli_error_dialog.title = "Could Not Load Scene"
_cli_error_dialog.min_size = Vector2i(560, 0)
ui.add_child(_cli_error_dialog)
_unsaved_changes_dialog = ConfirmationDialog.new() _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.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.add_button("Discard", false, "discard")
+166
View File
@@ -0,0 +1,166 @@
extends SceneTree
const IO := preload("res://scripts/stage_scene_io.gd")
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const FIXTURE_PATH := "user://cli_fixture.scene"
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_cleanup()
_test_parser()
await _test_load_into_edit()
await _test_play_mode()
await _test_missing_scene_error()
await _test_play_without_scene()
await _test_recovery_skipped()
_cleanup()
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("CLI scene test failed: " + message)
func _cleanup() -> void:
if FileAccess.file_exists(FIXTURE_PATH):
DirAccess.remove_absolute(FIXTURE_PATH)
if FileAccess.file_exists("user://sandbox_autosave.scene"):
DirAccess.remove_absolute("user://sandbox_autosave.scene")
func _write_fixture() -> void:
var fixture := {
"version": "1.0",
"scene_name": "CLI Fixture",
"camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5},
"objects": [{
"file_id": "terrain_1",
"type": "terrain",
"position": {"x": 20.0, "y": 30.0},
"rotation": 0.0,
"asset_id": "ground",
"scale": {"x": 1.0, "y": 1.0},
}],
"event_rules": [],
"metadata": {},
}
IO.write_file_safe(FIXTURE_PATH, fixture)
func _make_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
return stage
func _test_parser() -> void:
var empty: Dictionary = CliSceneLauncher.parse([])
_expect(String(empty["scene_path"]) == "" and not bool(empty["play"]) and String(empty["error"]) == "", "empty args parse clean")
var full: Dictionary = CliSceneLauncher.parse(["--play", "--scene", "res://x.scene"])
_expect(String(full["scene_path"]) == "res://x.scene" and bool(full["play"]) and String(full["error"]) == "", "--play --scene sets both")
var eq: Dictionary = CliSceneLauncher.parse(["--scene=res://x.scene"])
_expect(String(eq["scene_path"]) == "res://x.scene" and not bool(eq["play"]), "--scene= form sets path")
var positional: Dictionary = CliSceneLauncher.parse(["/abs/x.scene", "--play"])
_expect(String(positional["scene_path"]) == "/abs/x.scene" and bool(positional["play"]), "bare positional plus --play")
var play_only: Dictionary = CliSceneLauncher.parse(["--play"])
_expect(not String(play_only["error"]).is_empty(), "--play alone is an error")
var missing_value: Dictionary = CliSceneLauncher.parse(["--scene"])
_expect(not String(missing_value["error"]).is_empty(), "--scene without value is an error")
var unknown: Dictionary = CliSceneLauncher.parse(["--bogus"])
_expect(not String(unknown["error"]).is_empty(), "unknown option is an error")
var two_pos: Dictionary = CliSceneLauncher.parse(["a.scene", "b.scene"])
_expect(not String(two_pos["error"]).is_empty(), "two positionals are an error")
var flag_and_pos: Dictionary = CliSceneLauncher.parse(["--scene", "a.scene", "b.scene"])
_expect(not String(flag_and_pos["error"]).is_empty(), "--scene plus positional is an error")
func _test_load_into_edit() -> void:
_write_fixture()
var stage: Node2D = _make_stage()
await process_frame
stage._handle_cli_startup(["--scene", FIXTURE_PATH])
_expect(stage._current_scene_path == FIXTURE_PATH, "load adopts the scene path")
_expect(stage._world.get_child_count() == 1, "load reconstructs one object")
_expect(not stage._dirty, "load clears dirty")
_expect(stage._recent_scenes.size() > 0 and stage._recent_scenes[0] == FIXTURE_PATH, "load updates Open Recent")
_expect("cli_fixture.scene" in stage.get_window().title, "title shows the loaded scene file")
_expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "clean load leaves no autosave")
stage.queue_free()
await process_frame
func _test_play_mode() -> void:
_write_fixture()
var stage: Node2D = _make_stage()
await process_frame
stage._handle_cli_startup(["--play", "--scene", FIXTURE_PATH])
_expect(stage.current_mode == stage.StageMode.PLAY, "--play enters PLAY mode")
_expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "CLI PLAY suppresses the autosave write")
stage.queue_free()
await process_frame
func _test_missing_scene_error() -> void:
var stage: Node2D = _make_stage()
await process_frame
stage._handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"])
_expect(stage._cli_error_dialog != null and stage._cli_error_dialog.visible, "missing scene shows the error dialog")
_expect(not stage._cli_error_dialog.dialog_text.is_empty(), "missing scene error has dialog text")
_expect(stage._world.get_child_count() == 0, "missing scene leaves a blank stage")
_expect(stage.current_mode == stage.StageMode.EDIT, "missing scene stays in EDIT")
stage._cli_error_dialog.hide()
stage.queue_free()
await process_frame
func _test_play_without_scene() -> void:
var stage: Node2D = _make_stage()
await process_frame
stage._handle_cli_startup(["--play"])
_expect(stage._cli_error_dialog != null and stage._cli_error_dialog.visible, "--play without scene shows the error dialog")
_expect(stage._cli_error_dialog.dialog_text == "--play requires --scene <path>.", "--play error text matches the parser contract")
_expect(stage.current_mode == stage.StageMode.EDIT, "--play without scene never enters PLAY")
stage._cli_error_dialog.hide()
stage.queue_free()
await process_frame
func _test_recovery_skipped() -> void:
var stage: Node2D = _make_stage()
await process_frame
_write_fixture()
var autosave := {
"version": "1.0",
"scene_name": "Stale Autosave",
"objects": [],
"event_rules": [],
"metadata": {"autosave": true, "source_path": "", "modified_unix": Time.get_unix_time_from_system()},
}
IO.write_file_safe(stage.AUTOSAVE_PATH, autosave)
_expect(FileAccess.file_exists(stage.AUTOSAVE_PATH), "autosave fixture pre-written")
stage._handle_cli_startup(["--scene", FIXTURE_PATH])
_expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "explicit --scene retires the autosave")
var ui: Node = stage.get_node(NodePath("UI"))
_expect(ui.get_node_or_null(NodePath("AutosaveRecoveryDialog")) == null, "no recovery dialog is created for explicit --scene")
_expect(stage._current_scene_path == FIXTURE_PATH, "explicit --scene loads the requested scene")
stage.queue_free()
await process_frame
+1
View File
@@ -0,0 +1 @@
uid://bn0a3hoywsidc