Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1b4554ca9 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -388,19 +403,6 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
recreate a flinch after STOP. PLAY→DIRECT restoration also resets ANIMATED rigs' authored
|
recreate a flinch after STOP. PLAY→DIRECT restoration also resets ANIMATED rigs' authored
|
||||||
transform, facing, IK markers, and torso rotation. Null-guards + `push_warning` prefixed
|
transform, facing, IK markers, and torso rotation. Null-guards + `push_warning` prefixed
|
||||||
`StickmanRig:` throughout.
|
`StickmanRig:` throughout.
|
||||||
- **Phase 6 continuous terrain locomotion:** terrain following is PLAY-only. `begin_terrain_fall()`
|
|
||||||
arms a kinematic gravity fall for unsupported rigs; `snap_to_terrain()` is retained as a
|
|
||||||
compatibility alias, and `set_terrain_following_enabled(false)` clears contacts and restores
|
|
||||||
live leg targets from `AnimationTargets/*_Base`. The center support uses a three-ray fan
|
|
||||||
(`[-24, 0, +24]`); each lower-leg endpoint uses an independent three-ray fan (`[-4, 0, +4]`).
|
|
||||||
Rays accept only `TerrainBlock` colliders with upward-facing normals, exclude the rig's own
|
|
||||||
collision-proxy RIDs, and choose the highest valid surface. Runtime root elevation accumulates
|
|
||||||
across consecutive ramps/steps; unsupported rigs free-fall with bounded gravity, and walking
|
|
||||||
animation pauses until landing. The terrain controller writes final `IK_Targets/Left_Leg` /
|
|
||||||
`Right_Leg` positions from layered animation base markers, complementary continuous stance/swing
|
|
||||||
weights, world-space stance locks, and a predictive 24 px foot-penetration guard. It also filters
|
|
||||||
lower floors beneath overlapping ramps and applies centerline pelvis compensation. `DEBUG_TERRAIN`
|
|
||||||
is `false` in the shipped build; temporary `[terrain]` traces are opt-in.
|
|
||||||
- **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a
|
- **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a
|
||||||
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
|
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
|
||||||
WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)`
|
WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)`
|
||||||
@@ -687,14 +689,13 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
` is_in_ragdoll()` is false).
|
` is_in_ragdoll()` is false).
|
||||||
- `scripts/sandbox_stage.gd` — `class_name SandboxStage`, `extends Node2D`; the **Sandbox Stage
|
- `scripts/sandbox_stage.gd` — `class_name SandboxStage`, `extends Node2D`; the **Sandbox Stage
|
||||||
Builder** root controller (Phase 2, **not wired into the editor**; run via **F6** on
|
Builder** root controller (Phase 2, **not wired into the editor**; run via **F6** on
|
||||||
`res://scenes/sandbox_stage.tscn`). Owns the EDIT/DIRECT/PLAY mode state machine, placement mode,
|
`res://scenes/sandbox_stage.tscn`). Owns the EDIT/PLAY mode state machine, placement mode,
|
||||||
camera pan/zoom, deletion, status bar, and signal fan-out; instantiates `StageSpawner` /
|
camera pan/zoom, deletion, status bar, and signal fan-out; instantiates `StageSpawner` /
|
||||||
`StageSelection` / `StageGizmos` (via `preload` consts). `enum StageMode { EDIT, DIRECT, PLAY }`
|
`StageSelection` / `StageGizmos` (via `preload` consts). `enum StageMode { EDIT, PLAY }`
|
||||||
(default EDIT). EDIT freezes `RigidBody2D` props with `freeze = true` +
|
(default EDIT). EDIT freezes `RigidBody2D` props with `freeze = true` +
|
||||||
`freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs
|
`freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs
|
||||||
KINEMATIC, not STATIC) and keeps `StickmanRig`s ANIMATED (`set_ragdoll(false)` runs first);
|
KINEMATIC, not STATIC) and keeps `StickmanRig`s ANIMATED (`set_ragdoll(false)` runs first);
|
||||||
PLAY unfreezes props, enables PLAY-only terrain following/free-fall, and starts stickman queues
|
PLAY unfreezes props and ragdolls stickmen with `auto_recover = false`. Signals
|
||||||
with `auto_recover = false`; ragdoll/recover remain explicit actions. Signals
|
|
||||||
`mode_changed(mode)`, `object_placed(node)`, `object_selected(nodes)`, `object_deselected()`,
|
`mode_changed(mode)`, `object_placed(node)`, `object_selected(nodes)`, `object_deselected()`,
|
||||||
`object_deleted(nodes)`. Camera: middle-mouse pan, wheel zoom clamped to `@export min_zoom` /
|
`object_deleted(nodes)`. Camera: middle-mouse pan, wheel zoom clamped to `@export min_zoom` /
|
||||||
`max_zoom` (0.1 / 6.0); mouse→world via `_camera.get_global_mouse_position()` (no SubViewport).
|
`max_zoom` (0.1 / 6.0); mouse→world via `_camera.get_global_mouse_position()` (no SubViewport).
|
||||||
@@ -741,13 +742,12 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
next stage click appends `{"type":"walk_to","target":world_pos}` and which **Esc** cancels (Esc
|
next stage click appends `{"type":"walk_to","target":world_pos}` and which **Esc** cancels (Esc
|
||||||
priority: pending target → exit direct mode → existing placement clears). Builds a code-built
|
priority: pending target → exit direct mode → existing placement clears). Builds a code-built
|
||||||
`NavigationRegion2D` (`_build_navigation()`, child of the stage **not** `World` so it is never
|
`NavigationRegion2D` (`_build_navigation()`, child of the stage **not** `World` so it is never
|
||||||
hit-tested) carrying a procedural `NavigationPolygon` from exposed upward-surface ribbons and
|
hit-tested) carrying a procedural `NavigationPolygon` from per-`TerrainBlock` convex decomposition
|
||||||
short step bridges (`_rebake_navigation()`, world-space via `block.transform * p`); a `_nav_dirty`
|
(`_rebake_navigation()`, `Geometry2D.decompose_polygon_in_convex` + fan triangulation, world-space
|
||||||
flag re-bakes once per frame (`_process`) on terrain
|
via `block.transform * p`); a `_nav_dirty` flag re-bakes once per frame (`_process`) on terrain
|
||||||
place / move / rotate (`transform_committed`) / delete. Instantiates a `StageDirectorVisuals`
|
place / move / rotate (`transform_committed`) / delete. Instantiates a `StageDirectorVisuals`
|
||||||
overlay (`_build_director_visuals()`). **Play mode change (D3):** `_enter_play_mode()` no longer
|
overlay (`_build_director_visuals()`). **Play mode change (D3):** `_enter_play_mode()` no longer
|
||||||
auto-ragdolls stickmen — it sets each rig `auto_recover = false`, calls `begin_terrain_fall()`,
|
auto-ragdolls stickmen — it now sets each rig `auto_recover = false` and calls `start_queue()`;
|
||||||
then calls `start_queue()`;
|
|
||||||
`ragdoll`/`recover` are explicit queue actions; props still unfreeze. `_enter_edit_mode()`
|
`ragdoll`/`recover` are explicit queue actions; props still unfreeze. `_enter_edit_mode()`
|
||||||
`stop_queue()`s then `snap_to_standing()`s stickmen and re-enables the visuals. Wires
|
`stop_queue()`s then `snap_to_standing()`s stickmen and re-enables the visuals. Wires
|
||||||
`node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` →
|
`node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` →
|
||||||
@@ -789,16 +789,6 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
outside→overlap, does not repeat while overlapping, and re-arms after exit. The area loop's prop
|
outside→overlap, does not repeat while overlapping, and re-arms after exit. The area loop's prop
|
||||||
enumeration is correctly scoped. Verified by `tests/test_phase4c_area.gd` (19 assertions,
|
enumeration is correctly scoped. Verified by `tests/test_phase4c_area.gd` (19 assertions,
|
||||||
all passed) and manual editor verification of A entering an area causing B's speech bubble.
|
all passed) and manual editor verification of A entering an area causing B's speech bubble.
|
||||||
- **Phase 6 runtime terrain/events:** `_enter_play_mode()` calls `begin_terrain_fall()` before
|
|
||||||
`start_queue()`. EDIT/DIRECT disable terrain following and restore authored transforms. The
|
|
||||||
navigation bake uses shallow exposed upward-surface ribbons and connects neighboring surface
|
|
||||||
endpoints only within 64 px horizontal / 96 px vertical step limits; the `NavigationAgent2D`
|
|
||||||
remains at `(0, 385)` and follows the selected walk speed while terrain owns vertical root motion.
|
|
||||||
`_update_area_entry()` unions each visible stickman's current and previous world AABB so fast
|
|
||||||
free-fall still emits `entered_area`. In a reactive batch, the first takeover cancels existing
|
|
||||||
work once while later takeover actions remain ordered; `[ragdoll, recover]` therefore ragdolls,
|
|
||||||
waits for rest, then recovers. Terrain diagnostics are gated by `StickmanRig.DEBUG_TERRAIN` and
|
|
||||||
are off by default.
|
|
||||||
- **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no
|
- **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no
|
||||||
longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`,
|
longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`,
|
||||||
an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in
|
an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in
|
||||||
|
|||||||
@@ -510,19 +510,12 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
|
|||||||
**Play execution:**
|
**Play execution:**
|
||||||
|
|
||||||
- **Play mode now runs the director script** — stickmen stay **ANIMATED** and each rig's `start_queue()` is called (previously they auto-ragdolled on Play). `ragdoll` / `recover` are now **explicit queue actions**; a stickman only falls when directed. Props still unfreeze and tumble (and can knock a *directed* ragdoll). `auto_recover = false` in Play (the director owns recovery).
|
- **Play mode now runs the director script** — stickmen stay **ANIMATED** and each rig's `start_queue()` is called (previously they auto-ragdolled on Play). `ragdoll` / `recover` are now **explicit queue actions**; a stickman only falls when directed. Props still unfreeze and tumble (and can knock a *directed* ragdoll). `auto_recover = false` in Play (the director owns recovery).
|
||||||
- On **return to Edit** (or Direct): each stickman `stop_queue()`, disables terrain following, then `snap_to_standing()`, and `clear_reactive_actions()` strips any rule-injected (`reactive`) actions appended during the Play session — so reactive waypoint/badge markers do not accumulate across runs, while the authored sequential queue still replays. The waypoint overlay is then re-enabled.
|
- On **return to Edit** (or Direct): each stickman `stop_queue()` then `snap_to_standing()`, and `clear_reactive_actions()` strips any rule-injected (`reactive`) actions appended during the Play session — so reactive waypoint/badge markers do not accumulate across runs, while the authored sequential queue still replays. The waypoint overlay is then re-enabled.
|
||||||
- Multiple stickmen act simultaneously and independently (per-rig queues + per-rig runners, no shared state).
|
- Multiple stickmen act simultaneously and independently (per-rig queues + per-rig runners, no shared state).
|
||||||
|
|
||||||
**Continuous terrain following (Phase 6):**
|
|
||||||
|
|
||||||
- Terrain adaptation is enabled only in **Play**. `_enter_play_mode()` calls `begin_terrain_fall()` before `start_queue()`. An unsupported animated rig free-falls kinematically with bounded gravity; walking animation pauses while airborne and resumes on landing. EDIT and DIRECT disable the controller and restore the authored transform/standing pose.
|
|
||||||
- The center support sample uses three downward rays at horizontal offsets `[-24, 0, +24]`; each lower-leg endpoint uses an independent three-ray fan at `[-4, 0, +4]`. A hit must be a `TerrainBlock` with an upward-facing normal, and the rig's own collision-proxy RIDs are excluded. The highest valid surface wins, preventing a lower floor under a ramp from pulling a leg through the ramp.
|
|
||||||
- `master_rig.tscn` separates animated `AnimationTargets/Left_Leg_Base` and `Right_Leg_Base` markers from live `IK_Targets/Left_Leg` and `Right_Leg` targets. The rig blends complementary continuous stance/swing weights, holds stance contacts in world space, and applies a predictive 24 px foot guard for smooth contact transitions. Root elevation accumulates across ramps and steps, with centerline pelvis compensation; no foot/ankle bones are added.
|
|
||||||
- Navigation uses shallow ribbons over exposed upward-facing TerrainBlock edges, plus bridges between surface endpoints within 64 px horizontally and 96 px vertically for valid steps. The `NavigationAgent2D` remains at local `(0, +385)` and uses the selected walk speed; navigation supplies horizontal progress while terrain following owns vertical root motion and arrival avoids Y-correction jitter.
|
|
||||||
|
|
||||||
**Nav-mesh behavior:**
|
**Nav-mesh behavior:**
|
||||||
|
|
||||||
- A code-built `NavigationRegion2D` (child of the stage, not `World`, so it is never hit-tested) carries a procedural `NavigationPolygon` generated from exposed upward-facing TerrainBlock edges. Each surface becomes a shallow ribbon; neighboring endpoints are bridged only within the 64 px horizontal / 96 px vertical step limits, so solid interiors, walls, undersides, and large gaps are not route surfaces.
|
- A code-built `NavigationRegion2D` (child of the stage, not `World`, so it is never hit-tested) carries a procedural `NavigationPolygon` generated by per-`TerrainBlock` convex decomposition (`Geometry2D.decompose_polygon_in_convex` + fan triangulation) of each block's world-space polygon — robust to concavity and rotation.
|
||||||
- **Auto re-bake** on any terrain edit: the nav mesh is marked dirty on terrain place / move / rotate (`transform_committed`) / delete and re-baked once per frame (coalescing bursts) in `_process`.
|
- **Auto re-bake** on any terrain edit: the nav mesh is marked dirty on terrain place / move / rotate (`transform_committed`) / delete and re-baked once per frame (coalescing bursts) in `_process`.
|
||||||
- Each stickman's `NavigationAgent2D` is a child of the rig **at the feet** (local `(0, +385)` = `-FOOT_OFFSET`), so it sits on the ground-level mesh; agent and region share the default navigation map, layer 1.
|
- Each stickman's `NavigationAgent2D` is a child of the rig **at the feet** (local `(0, +385)` = `-FOOT_OFFSET`), so it sits on the ground-level mesh; agent and region share the default navigation map, layer 1.
|
||||||
- `walk_to(target)` treats `target` as a **feet/ground destination**; the rig converts ground-level path points back to root positions with `FOOT_OFFSET := (0, -385)`. Walking is **hybrid**: an **on-mesh target** follows the nav path (`_walk_mode = "nav"`), while an **off-mesh / unreachable target** (empty path **or** `is_target_reachable() == false`) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`) — it is **not** rejected with a warning and the rig does **not** stand still. Nav reads are deferred until the map has synced (`map_get_iteration_id(...) != 0`) and the path query is forced via `get_next_path_position()` before any reachability/finished check, so a fresh target is never misjudged as finished/unreachable after a single step.
|
- `walk_to(target)` treats `target` as a **feet/ground destination**; the rig converts ground-level path points back to root positions with `FOOT_OFFSET := (0, -385)`. Walking is **hybrid**: an **on-mesh target** follows the nav path (`_walk_mode = "nav"`), while an **off-mesh / unreachable target** (empty path **or** `is_target_reachable() == false`) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`) — it is **not** rejected with a warning and the rig does **not** stand still. Nav reads are deferred until the map has synced (`map_get_iteration_id(...) != 0`) and the path query is forced via `get_next_path_position()` before any reachability/finished check, so a fresh target is never misjudged as finished/unreachable after a single step.
|
||||||
@@ -600,12 +593,10 @@ All rule-builder context menus are **session-anchored**: the first popup in a fl
|
|||||||
|
|
||||||
### 20.1 Phase 4c — Trigger Area Reliability
|
### 20.1 Phase 4c — Trigger Area Reliability
|
||||||
|
|
||||||
Phase 4c fixes `entered_area` source matching and visible overlap detection. In PLAY, `_update_area_entry()` enumerates committed `TriggerArea` nodes and ANIMATED stickmen plus unfrozen props. Each movable is tested against the area's current transformed world AABB, using the visible object's world AABB (with a point fallback only for a future movable with no bounds). For Phase 6, the current bound is unioned with the previous physics-frame bound, so a fast-falling stickman cannot tunnel through an area between samples. This means a stickman's leg or another visible part can enter an area even when its feet anchor remains outside; the area itself still supports move/rotate/scale transforms.
|
Phase 4c fixes `entered_area` source matching and visible overlap detection. In PLAY, `_update_area_entry()` enumerates committed `TriggerArea` nodes and ANIMATED stickmen plus unfrozen props. Each movable is tested against the area's current transformed world AABB, using the visible object's world AABB (with a point fallback only for a future movable with no bounds). This means a stickman's leg or another visible part can enter an area even when its feet anchor remains outside; the area itself still supports move/rotate/scale transforms.
|
||||||
|
|
||||||
The event remains edge-triggered per `(area instance ID, movable instance ID)` pair: one `entered_area` event is emitted when that pair changes from outside to overlapping, no event repeats while it remains overlapping, and leaving then re-entering emits again. Rule matching requires both the trigger's source ID and target area ID; `source = -1` remains the explicit wildcard for any movable. The stage's prop enumeration is correctly scoped so unfrozen props participate without accidentally changing the area loop.
|
The event remains edge-triggered per `(area instance ID, movable instance ID)` pair: one `entered_area` event is emitted when that pair changes from outside to overlapping, no event repeats while it remains overlapping, and leaving then re-entering emits again. Rule matching requires both the trigger's source ID and target area ID; `source = -1` remains the explicit wildcard for any movable. The stage's prop enumeration is correctly scoped so unfrozen props participate without accidentally changing the area loop.
|
||||||
|
|
||||||
Reactive takeover batches preserve action order. The first takeover cancels existing primary/overlay work once; later takeover actions in the same batch remain queued. Thus a rule containing `ragdoll` followed by `recover` executes the ragdoll, waits for rest, and then recovers instead of allowing the later action to erase the earlier one.
|
|
||||||
|
|
||||||
Verified coverage in `tests/test_phase4c_area.gd`: **19 assertions passed**, including source-specific and wildcard matching, one-shot/re-entry edges, independent area/movable pairs, transformed areas, visible leg-level overlap with feet outside, speak queue normalization and visible speech execution, no-rule no-op behavior, stale action targets, and deletion cleanup. The editor verification also confirmed that `When Stickman A enters Area → Stickman B speaks` fires when A walks through the area.
|
Verified coverage in `tests/test_phase4c_area.gd`: **19 assertions passed**, including source-specific and wildcard matching, one-shot/re-entry edges, independent area/movable pairs, transformed areas, visible leg-level overlap with feet outside, speak queue normalization and visible speech execution, no-rule no-op behavior, stale action targets, and deletion cleanup. The editor verification also confirmed that `When Stickman A enters Area → Stickman B speaks` fires when A walks through the area.
|
||||||
|
|
||||||
**Prop collision detection:** Phase 4.5 replaces the old stickman feet-point test with physics contacts between `PropBlock` and the rig's per-part collision bodies. `PropBlock` still reports prop-vs-prop contacts through its physics signal; see §20.2 for routing, debouncing, and reaction details.
|
**Prop collision detection:** Phase 4.5 replaces the old stickman feet-point test with physics contacts between `PropBlock` and the rig's per-part collision bodies. `PropBlock` still reports prop-vs-prop contacts through its physics signal; see §20.2 for routing, debouncing, and reaction details.
|
||||||
@@ -968,7 +959,7 @@ Behavior:
|
|||||||
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
|
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
|
||||||
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
|
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
|
||||||
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
||||||
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. The recovery cycle is **pre-capture → ragdoll pose capture (7-ray ground probe) → mirror-aware re-anchor → snap (entry-captured chain offsets, `(−π, π]`-normalized bones, ankle clamp) → 2.0 s tween to the pre-ragdoll pose (arms/head bone-driven with `_natural_joint_target` for elbows; legs IK-driven) → finish (profile + bend flags + LookAt restored)**, with every per-joint bend decision editable in the `RAGDOLL_JOINT_BAND_LIMITS` / `RECOVERY_LEG_SIDE_FORCE` / `RECOVERY_JOINT_PATH` tables (Joint bend configuration (EDIT ME)). Diagnostics are gated behind `DEBUG_RECOVERY := false`. **Phase 6** adds PLAY-only `begin_terrain_fall()` / `set_terrain_following_enabled()`, center/per-foot TerrainBlock ray fans with self-proxy filtering, layered leg base markers vs live IK targets, continuous stance/swing blending with a predictive foot guard, accumulated root elevation/pelvis compensation across ramps/steps, and bounded free-fall. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. (Phase 4.5) builds ten top-level `AnimatableBody2D` collision proxies, explicitly synchronizes them to the rendered `Body/*` transforms each physics frame, derives the head circle center/radius from mounted geometry (90 px minimum), and applies bounded, contact-quadrant-aware bone flinches that decay to captured base rotations. `snap_to_standing()` and the stage's PLAY→DIRECT reset restore authored transforms and pose state. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. The recovery cycle is **pre-capture → ragdoll pose capture (7-ray ground probe) → mirror-aware re-anchor → snap (entry-captured chain offsets, `(−π, π]`-normalized bones, ankle clamp) → 2.0 s tween to the pre-ragdoll pose (arms/head bone-driven with `_natural_joint_target` for elbows; legs IK-driven) → finish (profile + bend flags + LookAt restored)**, with every per-joint bend decision editable in the `RAGDOLL_JOINT_BAND_LIMITS` / `RECOVERY_LEG_SIDE_FORCE` / `RECOVERY_JOINT_PATH` tables (Joint bend configuration (EDIT ME)). Diagnostics are gated behind `DEBUG_RECOVERY := false`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. (Phase 4.5) builds ten top-level `AnimatableBody2D` collision proxies, explicitly synchronizes them to the rendered `Body/*` transforms each physics frame, derives the head circle center/radius from mounted geometry (90 px minimum), and applies bounded, contact-quadrant-aware bone flinches that decay to captured base rotations. `snap_to_standing()` and the stage's PLAY→DIRECT reset restore authored transforms and pose state. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
|
||||||
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 2.0, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). `walk_right` is the **canonical walk** clip; `walk_left` remains baked in the library but is **not used at runtime** (facing is set explicitly and the rig root X-mirrors for LEFT). |
|
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 2.0, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). `walk_right` is the **canonical walk** clip; `walk_left` remains baked in the library but is **not used at runtime** (facing is set explicitly and the rig root X-mirrors for LEFT). |
|
||||||
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
|
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
|
||||||
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||||
@@ -1080,8 +1071,6 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
|||||||
|
|
||||||
> **Phase 5:** Adds drawing-surface clipping, shape dragging, an editor-wide shape clipboard (Copy/Paste across panels), shape Mirror X/Y (vertex recompute), and preview object Z-ordering (Send Back/Bring Forward) with Mirror X/Y (scale negation). The format evolved to v1.3: a new top-level `part_order` array stores preview Z-order; scale may be negative for mirrored parts; shape mirroring stores no new fields because it rewrites `points`. The default grid size changed from 5 px to 15 px, and the Snap to Grid checkmark now renders correctly when toggled. v1.0–v1.2 files remain backward compatible and are migrated on load.
|
> **Phase 5:** Adds drawing-surface clipping, shape dragging, an editor-wide shape clipboard (Copy/Paste across panels), shape Mirror X/Y (vertex recompute), and preview object Z-ordering (Send Back/Bring Forward) with Mirror X/Y (scale negation). The format evolved to v1.3: a new top-level `part_order` array stores preview Z-order; scale may be negative for mirrored parts; shape mirroring stores no new fields because it rewrites `points`. The default grid size changed from 5 px to 15 px, and the Snap to Grid checkmark now renders correctly when toggled. v1.0–v1.2 files remain backward compatible and are migrated on load.
|
||||||
|
|
||||||
> **Phase 6 — Continuous Dynamic IK & Terrain-Following Locomotion (implemented and editor-verified):** PLAY enables a kinematic terrain controller. Unsupported rigs free-fall under bounded gravity; walking animation pauses while airborne and resumes on landing. The centerline uses a three-ray terrain fan and each lower-leg endpoint uses an independent three-ray fan; only upward-facing `TerrainBlock` hits are accepted, with self collision proxies excluded. `AnimationTargets/*_Base` markers hold the animation pose while live leg IK targets receive continuous stance/swing blending, world-space stance locks, a predictive foot guard, and centerline pelvis/root compensation. Runtime root elevation accumulates over ramps and steps. EDIT/DIRECT disable terrain following and restore authored transforms. The Sandbox navigation bake uses exposed upward-surface ribbons with short step bridges; `NavigationAgent2D` remains at `(0, +385)` and terrain owns vertical motion while arrival remains baseline-aligned. Trigger areas use swept visible bounds across physics frames to detect fast-fall entries. Reactive takeover batches preserve order, so `ragdoll` followed by `recover` executes both in sequence. `DEBUG_TERRAIN` is off by default. No foot/ankle bones were added.
|
|
||||||
|
|
||||||
> **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`.
|
> **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`.
|
||||||
|
|
||||||
> **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0–v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline.
|
> **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0–v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline.
|
||||||
|
|||||||
@@ -1,492 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"scene_name": "Untitled",
|
|
||||||
"camera": {
|
|
||||||
"position": {
|
|
||||||
"x": 1070.22937011719,
|
|
||||||
"y": -54.6092796325684
|
|
||||||
},
|
|
||||||
"zoom": 0.375657439231873
|
|
||||||
},
|
|
||||||
"objects": [
|
|
||||||
{
|
|
||||||
"file_id": "terrain_1",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": -1344.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_2",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": -1152.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_3",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": -960.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_4",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": -768.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_5",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": -576.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_6",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": -384.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_7",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": -192.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_8",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 0.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_9",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 192.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_10",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 384.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_11",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 576.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_12",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 768.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_13",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 960.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "stickman_1",
|
|
||||||
"type": "stickman",
|
|
||||||
"position": {
|
|
||||||
"x": -335.466552734375,
|
|
||||||
"y": 132.287109375
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"stk_path": "res://stickmen/break.stk",
|
|
||||||
"facing_profile": "FORWARD",
|
|
||||||
"action_queue": [
|
|
||||||
{
|
|
||||||
"type": "walk_to",
|
|
||||||
"target": {
|
|
||||||
"x": 3112.55639648438,
|
|
||||||
"y": 493.14306640625
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_14",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 349.146484375,
|
|
||||||
"y": 394.255798339844
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "step",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_15",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 604.29833984375,
|
|
||||||
"y": 139.10400390625
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "step",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_16",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 860.076782226563,
|
|
||||||
"y": -115.671752929688
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "step",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_17",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 1113.59301757813,
|
|
||||||
"y": -367.9462890625
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "step",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_18",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 1372.07690429688,
|
|
||||||
"y": -625.188110351563
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "step",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_19",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 1152.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_20",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 1344.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_21",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 1536.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_22",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 1728.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_23",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 1920.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_24",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 2112.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_25",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 2304.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_26",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 2496.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_27",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 2688.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_28",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 2880.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "terrain_29",
|
|
||||||
"type": "terrain",
|
|
||||||
"position": {
|
|
||||||
"x": 3072.0,
|
|
||||||
"y": 544.0
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"asset_id": "ground",
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"file_id": "area_1",
|
|
||||||
"type": "area",
|
|
||||||
"position": {
|
|
||||||
"x": 1650.52270507813,
|
|
||||||
"y": -186.824584960938
|
|
||||||
},
|
|
||||||
"rotation": 0.0,
|
|
||||||
"scale": {
|
|
||||||
"x": 1.0,
|
|
||||||
"y": 1.0
|
|
||||||
},
|
|
||||||
"size": {
|
|
||||||
"x": 96.0,
|
|
||||||
"y": 96.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"event_rules": [
|
|
||||||
{
|
|
||||||
"id": 0,
|
|
||||||
"trigger": {
|
|
||||||
"type": "entered_area",
|
|
||||||
"source_id": "stickman_1",
|
|
||||||
"target_id": "area_1",
|
|
||||||
"params": {}
|
|
||||||
},
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"type": "ragdoll",
|
|
||||||
"target_id": "stickman_1",
|
|
||||||
"params": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "wait",
|
|
||||||
"target_id": "stickman_1",
|
|
||||||
"params": {
|
|
||||||
"duration": 2.0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "recover",
|
|
||||||
"target_id": "stickman_1",
|
|
||||||
"params": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"created_unix": 1789479607,
|
|
||||||
"modified_unix": 1789529735.774,
|
|
||||||
"autosave": false,
|
|
||||||
"source_path": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-12
@@ -190,7 +190,7 @@ tracks/1/keys = {
|
|||||||
tracks/2/type = "value"
|
tracks/2/type = "value"
|
||||||
tracks/2/imported = false
|
tracks/2/imported = false
|
||||||
tracks/2/enabled = true
|
tracks/2/enabled = true
|
||||||
tracks/2/path = NodePath("AnimationTargets/Right_Leg_Base:position")
|
tracks/2/path = NodePath("IK_Targets/Right_Leg:position")
|
||||||
tracks/2/interp = 2
|
tracks/2/interp = 2
|
||||||
tracks/2/loop_wrap = true
|
tracks/2/loop_wrap = true
|
||||||
tracks/2/keys = {
|
tracks/2/keys = {
|
||||||
@@ -202,7 +202,7 @@ tracks/2/keys = {
|
|||||||
tracks/3/type = "value"
|
tracks/3/type = "value"
|
||||||
tracks/3/imported = false
|
tracks/3/imported = false
|
||||||
tracks/3/enabled = true
|
tracks/3/enabled = true
|
||||||
tracks/3/path = NodePath("AnimationTargets/Left_Leg_Base:position")
|
tracks/3/path = NodePath("IK_Targets/Left_Leg:position")
|
||||||
tracks/3/interp = 2
|
tracks/3/interp = 2
|
||||||
tracks/3/loop_wrap = true
|
tracks/3/loop_wrap = true
|
||||||
tracks/3/keys = {
|
tracks/3/keys = {
|
||||||
@@ -266,7 +266,7 @@ tracks/1/keys = {
|
|||||||
tracks/2/type = "value"
|
tracks/2/type = "value"
|
||||||
tracks/2/imported = false
|
tracks/2/imported = false
|
||||||
tracks/2/enabled = true
|
tracks/2/enabled = true
|
||||||
tracks/2/path = NodePath("AnimationTargets/Right_Leg_Base:position")
|
tracks/2/path = NodePath("IK_Targets/Right_Leg:position")
|
||||||
tracks/2/interp = 2
|
tracks/2/interp = 2
|
||||||
tracks/2/loop_wrap = true
|
tracks/2/loop_wrap = true
|
||||||
tracks/2/keys = {
|
tracks/2/keys = {
|
||||||
@@ -278,7 +278,7 @@ tracks/2/keys = {
|
|||||||
tracks/3/type = "value"
|
tracks/3/type = "value"
|
||||||
tracks/3/imported = false
|
tracks/3/imported = false
|
||||||
tracks/3/enabled = true
|
tracks/3/enabled = true
|
||||||
tracks/3/path = NodePath("AnimationTargets/Left_Leg_Base:position")
|
tracks/3/path = NodePath("IK_Targets/Left_Leg:position")
|
||||||
tracks/3/interp = 2
|
tracks/3/interp = 2
|
||||||
tracks/3/loop_wrap = true
|
tracks/3/loop_wrap = true
|
||||||
tracks/3/keys = {
|
tracks/3/keys = {
|
||||||
@@ -555,14 +555,6 @@ position = Vector2(110, 390)
|
|||||||
[node name="Left_Leg" type="Marker2D" parent="IK_Targets" unique_id=63598929]
|
[node name="Left_Leg" type="Marker2D" parent="IK_Targets" unique_id=63598929]
|
||||||
position = Vector2(-110, 380)
|
position = Vector2(-110, 380)
|
||||||
|
|
||||||
[node name="AnimationTargets" type="Node2D" parent="."]
|
|
||||||
|
|
||||||
[node name="Right_Leg_Base" type="Marker2D" parent="AnimationTargets"]
|
|
||||||
position = Vector2(110, 390)
|
|
||||||
|
|
||||||
[node name="Left_Leg_Base" type="Marker2D" parent="AnimationTargets"]
|
|
||||||
position = Vector2(-110, 380)
|
|
||||||
|
|
||||||
[node name="RayCast_LeftLeg" type="RayCast2D" parent="IK_Targets" unique_id=2000000001]
|
[node name="RayCast_LeftLeg" type="RayCast2D" parent="IK_Targets" unique_id=2000000001]
|
||||||
position = Vector2(-96, 200)
|
position = Vector2(-96, 200)
|
||||||
target_position = Vector2(0, 200)
|
target_position = Vector2(0, 200)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
# Phase 6 — Continuous Dynamic IK & Terrain-Following Locomotion
|
|
||||||
|
|
||||||
Status: implemented and verified in the Godot editor. This document records the shipped runtime behavior and supersedes the rejected authoring-time normalization design.
|
|
||||||
|
|
||||||
## 1. Goal and scope
|
|
||||||
|
|
||||||
During PLAY, every animated stickman continuously conforms its feet and pelvis to TerrainBlock ground. An unsupported rig enters a kinematic, gravity-driven free-fall; supported rigs independently sample both lower-leg endpoints, hold stance contacts, preserve lifted swing arcs, and compensate the root for ramps and steps. The lower-leg endpoints are the foot contacts: Phase 6 adds no foot/ankle bones or new visual geometry.
|
|
||||||
|
|
||||||
This is runtime locomotion. The phase does not change palette placement or stored walk-action coordinates; both remain existing feet-space values. Ragdoll physics and recovery stay independent from animated terrain contacts.
|
|
||||||
|
|
||||||
## 2. Constraints
|
|
||||||
|
|
||||||
- StickmanRig owns live IK_Targets and runs physics processing; SandboxStage owns the EDIT-to-PLAY handoff.
|
|
||||||
- TerrainBlock is a solid StaticBody2D. Terrain, props, and rig collision proxies share layer 1, so probes accept only TerrainBlock hits with upward-facing normals.
|
|
||||||
- The walk_right and legacy walk_left clips write the layered `AnimationTargets/*_Base` markers; terrain code is the sole runtime writer of the live leg IK targets.
|
|
||||||
- NavigationAgentAnchor remains at (0, 385) and FOOT_OFFSET remains the sole feet-space to root-space conversion.
|
|
||||||
- Navigation is baked from exposed upward-facing terrain edges as ribbons, with bounded bridges for valid steps; solid terrain interiors are not route surfaces.
|
|
||||||
|
|
||||||
## 3. Layered IK ownership
|
|
||||||
|
|
||||||
### 3.1 Scene structure
|
|
||||||
|
|
||||||
`master_rig.tscn` contains base-pose markers which are not read by the Skeleton modification stack:
|
|
||||||
|
|
||||||
Master (StickmanRig)
|
|
||||||
├── IK_Targets
|
|
||||||
│ ├── Left_Leg # final target; TwoBoneIK reads this
|
|
||||||
│ └── Right_Leg # final target; TwoBoneIK reads this
|
|
||||||
└── AnimationTargets
|
|
||||||
├── Left_Leg_Base # authored walk animation writes here
|
|
||||||
└── Right_Leg_Base # authored walk animation writes here
|
|
||||||
|
|
||||||
The two leg-position tracks in the walk clips write the `AnimationTargets` markers. Existing hand, torso, and head tracks keep their current targets. The rig is then the sole runtime writer of the live `IK_Targets/Left_Leg` and `IK_Targets/Right_Leg` positions.
|
|
||||||
|
|
||||||
### 3.2 Rig controller API and state
|
|
||||||
|
|
||||||
StickmanRig owns a private controller with these constants/state:
|
|
||||||
|
|
||||||
TERRAIN_PROBE_UP = 96
|
|
||||||
TERRAIN_PROBE_DOWN = 700
|
|
||||||
TERRAIN_NORMAL_MIN_Y = -0.15
|
|
||||||
FOOT_CONTACT_EPSILON = 2
|
|
||||||
FOOT_BLEND_RESPONSE = 14
|
|
||||||
TERRAIN_SUPPORT_RANGE = 160
|
|
||||||
TERRAIN_MAX_FOOT_SURFACE_DROP = 72
|
|
||||||
TERRAIN_FOOT_GUARD_HEIGHT = 24
|
|
||||||
PELVIS_MAX_OFFSET = 240
|
|
||||||
PELVIS_RESPONSE = 10
|
|
||||||
WALK_CYCLE_DURATION = 0.8
|
|
||||||
|
|
||||||
_leg_base_markers # Left_Leg/Right_Leg -> Marker2D
|
|
||||||
_foot_contacts # leg -> {valid, world_pos, normal, locked}
|
|
||||||
_terrain_pelvis_offset
|
|
||||||
|
|
||||||
New methods:
|
|
||||||
|
|
||||||
begin_terrain_fall() -> void
|
|
||||||
snap_to_terrain() -> void # compatibility alias; arms the PLAY controller
|
|
||||||
set_terrain_following_enabled(enabled: bool) -> void
|
|
||||||
_update_terrain_following(delta) -> void
|
|
||||||
_probe_terrain_below(world_point) -> Dictionary
|
|
||||||
|
|
||||||
`begin_terrain_fall()` is called on PLAY entry and is safe/idempotent: it enables the controller, clears contacts, resets fall velocity, and starts unsupported rigs in free-fall. `snap_to_terrain()` remains a compatibility alias. `set_terrain_following_enabled(false)` clears contacts, resets terrain state, and copies base markers back to live targets. Missing nodes/world state warn with a `StickmanRig:` prefix and skip safely.
|
|
||||||
|
|
||||||
### 3.3 Probe contract
|
|
||||||
|
|
||||||
The center support sample uses a three-ray horizontal fan at offsets `[-24, 0, +24]`. Each lower-leg endpoint uses its own three-ray fan at `[-4, 0, +4]`; each ray starts 96 px above its world X and ends 700 px below. A result is accepted only when:
|
|
||||||
|
|
||||||
1. its collider is a TerrainBlock;
|
|
||||||
2. its normal has y <= TERRAIN_NORMAL_MIN_Y; and
|
|
||||||
3. it is in the stated probe span.
|
|
||||||
|
|
||||||
The fan selects the highest valid surface (smallest world Y). Queries use layer 1, reject non-`TerrainBlock` colliders, reject normals below the upward-facing threshold, and exclude the rig's own collision-proxy RIDs. Invalid rays never invent a floor. World/global transforms preserve LEFT root mirroring.
|
|
||||||
|
|
||||||
## 4. Per-frame solve order
|
|
||||||
|
|
||||||
In `StickmanRig._physics_process()`, terrain following runs only while terrain following is enabled and `state == ANIMATED`, after walking moves the root and before collision proxies synchronize:
|
|
||||||
|
|
||||||
AnimationPlayer updates AnimationTargets base pose
|
|
||||||
↓
|
|
||||||
walking moves root along nav/direct baseline
|
|
||||||
↓
|
|
||||||
terrain controller:
|
|
||||||
sample contacts → gait weights → foot locks → pelvis offset → final targets
|
|
||||||
↓
|
|
||||||
Skeleton TwoBoneIK solves final targets
|
|
||||||
↓
|
|
||||||
collision proxies synchronize
|
|
||||||
|
|
||||||
The shipped ordering reads the base markers, applies walking/root motion, solves terrain contacts, then synchronizes proxies. `RAGDOLL` and `RECOVERING` do not run the animated terrain controller or write the live leg targets.
|
|
||||||
|
|
||||||
RECOVERING and RAGDOLL are excluded because they own or hide the same targets; the terrain controller does not write them in those states.
|
|
||||||
|
|
||||||
## 5. Stance/swing blending
|
|
||||||
|
|
||||||
The current walk clips are 0.8 seconds and alternate legs. The controller computes complementary continuous cosine weights over that cycle:
|
|
||||||
|
|
||||||
| Cycle interval | Right leg | Left leg |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 0.00–0.40 | stance weight 1 → 0 | swing weight 0 → 1 |
|
|
||||||
| 0.40–0.80 | swing weight 0 → 1 | stance weight 1 → 0 |
|
|
||||||
|
|
||||||
The complementary cosine weights provide a continuous transition; idle uses stance weight 1.0 for both legs.
|
|
||||||
|
|
||||||
- At stance entry, capture the valid terrain point as a world-space foot lock. While planted, transform the stable world point back to rig-local space; this prevents horizontal foot skating as the root moves.
|
|
||||||
- In swing, retain the animation base target and apply only the terrain influence needed to prevent penetration. A 24 px predictive foot guard ramps that correction in before the endpoint reaches the surface.
|
|
||||||
- Between phases, blend base and lock/contact targets with response limiting to avoid knee pops at step edges.
|
|
||||||
- At idle both legs are stance. They can independently lock to a ramp or two different stair heights; the root does not rotate.
|
|
||||||
|
|
||||||
## 6. Pelvis/root compensation
|
|
||||||
|
|
||||||
Calculate a desired vertical correction from the centerline terrain fan, clamp it to `PELVIS_MAX_OFFSET` (240 px), and ease it with `PELVIS_RESPONSE`.
|
|
||||||
|
|
||||||
runtime root Y += terrain pelvis correction
|
|
||||||
|
|
||||||
Runtime root elevation accumulates directly as the controller advances across consecutive ramps and steps; the last-frame correction variable is cleared each frame and is not subtracted from the root. If the centerline has no supported TerrainBlock, the root free-falls with `TERRAIN_GRAVITY` up to `TERRAIN_MAX_FALL_SPEED`, and walk animation pauses until landing. Landing applies the full support correction to prevent tunneling. Feet too far below the center support surface are ignored so an overlapping lower floor cannot pull a leg through a ramp.
|
|
||||||
|
|
||||||
## 7. Play entry and walkable-surface navigation
|
|
||||||
|
|
||||||
On PLAY entry, after props unfreeze, `SandboxStage` calls `begin_terrain_fall()` for every rig before `start_queue()`. This is the only mode that enables terrain free-fall/following. EDIT and DIRECT call `set_terrain_following_enabled(false)` and restore the captured authored transform/standing pose; terrain following never mutates authored placement.
|
|
||||||
|
|
||||||
### 7.1 Walkable-surface ribbon
|
|
||||||
|
|
||||||
SandboxStage replaces the full-solid NavigationPolygon bake with a derived walkable-surface ribbon:
|
|
||||||
|
|
||||||
1. Transform every TerrainBlock polygon into stage-world coordinates.
|
|
||||||
2. Extract only upward-facing boundary edges from the clockwise TerrainBlock polygons; discard vertical edges and undersides.
|
|
||||||
3. Expand each accepted edge into a narrow, configurable navigation strip just above the collision surface.
|
|
||||||
4. Bridge neighboring surface endpoints only when the horizontal gap is at most 64 px and the height difference is at most 96 px, connecting authored stair treads without making cliffs walkable.
|
|
||||||
5. Bake the resulting strips as the NavigationPolygon. Large cliffs, terrain interiors, walls, undersides, and unsupported gaps remain disconnected.
|
|
||||||
|
|
||||||
The ribbon follows ramps continuously and connects valid stair treads while deliberately refusing impossible steps. Terrain collision remains authoritative for the final ankle probe; the ribbon supplies only route topology.
|
|
||||||
|
|
||||||
### 7.2 Walking alignment
|
|
||||||
|
|
||||||
Walking retains `NavigationAgentAnchor` at `(0, 385)` and synchronizes `NavigationAgent2D.max_speed` with the selected walk speed. Navigation supplies horizontal progress; terrain following owns vertical root motion. Arrival compares horizontal/root baseline distance and does not let terrain Y correction create waypoint jitter. On-mesh paths follow the ribbon; unreachable/off-mesh targets use latched direct steering.
|
|
||||||
|
|
||||||
### 7.3 Trigger and reactive-action behavior
|
|
||||||
|
|
||||||
`SandboxStage._update_area_entry()` tests the visible stickman's world AABB and unions it with the previous physics-frame AABB. This swept bound catches `entered_area` events during fast PLAY-only free-fall instead of relying on a single sampled pose. Rules still dispatch while the rig is `ANIMATED` and airborne.
|
|
||||||
|
|
||||||
Reactive takeover actions preserve their authored order within one rule batch. The first takeover cancels existing primary/overlay work; later takeover actions remain queued, so a `[ragdoll, recover]` rule executes ragdoll first, waits for rest, then recovers. The runtime terrain trace is gated by `DEBUG_TERRAIN := false` and is off by default.
|
|
||||||
|
|
||||||
## 8. Files and ownership
|
|
||||||
|
|
||||||
| File | Implemented responsibility |
|
|
||||||
| --- | --- |
|
|
||||||
| scripts/stickman_rig.gd | Base-marker resolution, PLAY-only kinematic free-fall, center/per-foot terrain fans, contact locks, gait weights, predictive foot guard, accumulated root elevation, pelvis controller, and terrain-state cleanup. |
|
|
||||||
| master_rig.tscn | `AnimationTargets/Left_Leg_Base` and `Right_Leg_Base` markers plus redirected leg animation tracks. |
|
|
||||||
| scripts/sandbox_stage.gd | PLAY terrain-controller handoff; EDIT/DIRECT authored-state restoration; exposed-surface ribbon baking; swept trigger-area bounds; and reactive action dispatch. |
|
|
||||||
| scripts/terrain_utils.gd | Add pure world-polygon edge classification/strip helpers if extracting that geometry keeps SandboxStage focused on lifecycle and orchestration. |
|
|
||||||
| scripts/terrain_block.gd, scripts/stage_spawner.gd | No behavioral change. |
|
|
||||||
|
|
||||||
## 9. Milestones and acceptance
|
|
||||||
|
|
||||||
### 1. Play Snap
|
|
||||||
|
|
||||||
- [x] PLAY-only hovering rigs free-fall onto flat ground, ramps, and stair treads before/while queues run.
|
|
||||||
- [x] EDIT/DIRECT disable terrain following and restore the authored transform.
|
|
||||||
|
|
||||||
### 2. Idle Slope IK
|
|
||||||
|
|
||||||
- [x] Independent lower-leg endpoint contacts visibly flex knees across ramps/stair edges.
|
|
||||||
- [x] Probes accept only TerrainBlock upward surfaces and exclude rig proxies; RAGDOLL/RECOVERING are excluded.
|
|
||||||
|
|
||||||
### 3. Walk Cycle Blending
|
|
||||||
|
|
||||||
- [x] Stance feet remain planted while opposite feet follow lifted swing arcs with continuous weights and a predictive guard.
|
|
||||||
- [x] Uphill/downhill and stairs avoid dragging, penetration, and one-frame knee/pelvis pops in editor verification.
|
|
||||||
- [x] Cancel, ragdoll, and recovery safely clear controller state.
|
|
||||||
|
|
||||||
### 4. Navigation Sync
|
|
||||||
|
|
||||||
- [x] The navigation anchor stays (0, 385); navigation speed follows the selected walk speed and terrain code owns vertical motion.
|
|
||||||
- [x] The baked navigation mesh covers exposed ribbons and valid connected steps, not solid terrain interiors or undersides.
|
|
||||||
- [x] Flat, ramps, stairs, and off-mesh arrival remain deterministic without terrain-Y waypoint jitter.
|
|
||||||
- [x] Collision proxies synchronize after final terrain IK.
|
|
||||||
- [x] Swept visible bounds detect trigger-area entry during fast free-fall; reactive `[ragdoll, recover]` batches execute in order.
|
|
||||||
|
|
||||||
## 10. Verification record
|
|
||||||
|
|
||||||
Editor verification covered hovering PLAY entry, flat ground, ramps, consecutive ramps, steps, walking climbs, falling/landing, trigger-area entry during free-fall, ragdoll/recover reactive rules, and EDIT/DIRECT restoration. The shipped design confirms:
|
|
||||||
|
|
||||||
- the two-layer animation/final-IK target structure;
|
|
||||||
- world-space stance locks with phase-weighted swing blending;
|
|
||||||
- additive, clamped runtime pelvis correction without authored/nav-offset changes;
|
|
||||||
- terrain-only contacts with center/per-foot fans and PLAY-only free-fall; and
|
|
||||||
- a derived walkable-surface ribbon that replaces full-solid navigation baking.
|
|
||||||
|
|
||||||
The temporary terrain diagnostics remain available behind `DEBUG_TERRAIN`, but the gate is `false` in the shipped build.
|
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dmoyg7nq46v07
|
||||||
+59
-71
@@ -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
|
||||||
@@ -327,7 +329,6 @@ var _rule_more_popup: PopupMenu = null
|
|||||||
## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>"
|
## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>"
|
||||||
## and "<rig_id>:<prop_id>".
|
## and "<rig_id>:<prop_id>".
|
||||||
var _area_overlap: Dictionary = {}
|
var _area_overlap: Dictionary = {}
|
||||||
var _area_previous_bounds: Dictionary = {}
|
|
||||||
var _collision_pairs: Dictionary = {}
|
var _collision_pairs: Dictionary = {}
|
||||||
|
|
||||||
## Lightweight toast text + countdown (cleared in _process on expiry).
|
## Lightweight toast text + countdown (cleared in _process on expiry).
|
||||||
@@ -423,7 +424,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 +609,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
|
||||||
@@ -635,7 +636,6 @@ func _enter_edit_shared() -> void:
|
|||||||
if node is STICKMAN_RIG:
|
if node is STICKMAN_RIG:
|
||||||
var rig := node as STICKMAN_RIG
|
var rig := node as STICKMAN_RIG
|
||||||
rig.stop_queue()
|
rig.stop_queue()
|
||||||
rig.set_terrain_following_enabled(false)
|
|
||||||
rig.snap_to_standing()
|
rig.snap_to_standing()
|
||||||
rig.clear_reactive_actions()
|
rig.clear_reactive_actions()
|
||||||
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
|
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
|
||||||
@@ -655,7 +655,6 @@ func _enter_edit_shared() -> void:
|
|||||||
_director_visuals.set_enabled(true)
|
_director_visuals.set_enabled(true)
|
||||||
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
|
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
|
||||||
_area_overlap.clear()
|
_area_overlap.clear()
|
||||||
_area_previous_bounds.clear()
|
|
||||||
_collision_pairs.clear()
|
_collision_pairs.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -716,7 +715,6 @@ func _enter_play_mode() -> void:
|
|||||||
_set_direct_controls_visible(false)
|
_set_direct_controls_visible(false)
|
||||||
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
|
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
|
||||||
_area_overlap.clear()
|
_area_overlap.clear()
|
||||||
_area_previous_bounds.clear()
|
|
||||||
_collision_pairs.clear()
|
_collision_pairs.clear()
|
||||||
for node: Node2D in _world_children_selectable():
|
for node: Node2D in _world_children_selectable():
|
||||||
if node is RigidBody2D:
|
if node is RigidBody2D:
|
||||||
@@ -726,7 +724,6 @@ func _enter_play_mode() -> void:
|
|||||||
if node is STICKMAN_RIG:
|
if node is STICKMAN_RIG:
|
||||||
var rig := node as STICKMAN_RIG
|
var rig := node as STICKMAN_RIG
|
||||||
rig.auto_recover = false
|
rig.auto_recover = false
|
||||||
rig.begin_terrain_fall()
|
|
||||||
rig.start_queue()
|
rig.start_queue()
|
||||||
rig_count += 1
|
rig_count += 1
|
||||||
_stage_dbg("PLAY rigs=%d" % rig_count)
|
_stage_dbg("PLAY rigs=%d" % rig_count)
|
||||||
@@ -1459,6 +1456,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 +1946,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")
|
||||||
@@ -2021,15 +2061,14 @@ func _build_navigation() -> void:
|
|||||||
_rebake_navigation()
|
_rebake_navigation()
|
||||||
|
|
||||||
|
|
||||||
## Rebuild navigation from exposed top edges only. Each qualifying terrain edge
|
## Rebuild the navigation mesh from every TerrainBlock child of World: each
|
||||||
## becomes a shallow ribbon just above its collision surface, so agents route
|
## block's world-space polygon is convex-decomposed and fan-triangulated into
|
||||||
## over floors/ramps rather than through solid terrain polygons.
|
## one manual NavigationPolygon.
|
||||||
func _rebake_navigation() -> void:
|
func _rebake_navigation() -> void:
|
||||||
if _nav_region == null or not is_instance_valid(_nav_region):
|
if _nav_region == null or not is_instance_valid(_nav_region):
|
||||||
return
|
return
|
||||||
var verts := PackedVector2Array()
|
var verts := PackedVector2Array()
|
||||||
var triangles: Array[PackedInt32Array] = []
|
var triangles: Array[PackedInt32Array] = []
|
||||||
var surfaces: Array[Dictionary] = []
|
|
||||||
for child: Node in _world.get_children():
|
for child: Node in _world.get_children():
|
||||||
var block := child as TerrainBlock
|
var block := child as TerrainBlock
|
||||||
if block == null:
|
if block == null:
|
||||||
@@ -2037,28 +2076,14 @@ func _rebake_navigation() -> void:
|
|||||||
var pts := PackedVector2Array()
|
var pts := PackedVector2Array()
|
||||||
for p: Vector2 in block.polygon_points:
|
for p: Vector2 in block.polygon_points:
|
||||||
pts.append(block.transform * p)
|
pts.append(block.transform * p)
|
||||||
for i: int in pts.size():
|
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
|
||||||
var a := pts[i]
|
if pieces.is_empty():
|
||||||
var b := pts[(i + 1) % pts.size()]
|
pieces = [pts]
|
||||||
var edge := b - a
|
for piece: PackedVector2Array in pieces:
|
||||||
if edge.length_squared() <= 0.001:
|
var base: int = verts.size()
|
||||||
continue
|
verts.append_array(piece)
|
||||||
# TerrainUtils stores clockwise polygons; this right normal is
|
for i: int in range(1, piece.size() - 1):
|
||||||
# outward, so only its upward-facing edges are walkable.
|
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
|
||||||
var outward := Vector2(edge.y, -edge.x).normalized()
|
|
||||||
if outward.y > -0.15:
|
|
||||||
continue
|
|
||||||
surfaces.append({"a": a, "b": b, "normal": outward})
|
|
||||||
for surface: Dictionary in surfaces:
|
|
||||||
_append_navigation_ribbon(verts, triangles, surface["a"], surface["b"], surface["normal"])
|
|
||||||
# Join only short, climbable gaps between exposed surface ends. This connects
|
|
||||||
# authored stair treads without making cliffs or terrain interiors walkable.
|
|
||||||
for i: int in surfaces.size():
|
|
||||||
for j: int in range(i + 1, surfaces.size()):
|
|
||||||
for a: Vector2 in [surfaces[i]["a"], surfaces[i]["b"]]:
|
|
||||||
for b: Vector2 in [surfaces[j]["a"], surfaces[j]["b"]]:
|
|
||||||
if absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0:
|
|
||||||
_append_navigation_ribbon(verts, triangles, a, b, Vector2.UP)
|
|
||||||
var poly := NavigationPolygon.new()
|
var poly := NavigationPolygon.new()
|
||||||
poly.set_vertices(verts)
|
poly.set_vertices(verts)
|
||||||
for tri: PackedInt32Array in triangles:
|
for tri: PackedInt32Array in triangles:
|
||||||
@@ -2067,24 +2092,6 @@ func _rebake_navigation() -> void:
|
|||||||
if DEBUG_STAGE:
|
if DEBUG_STAGE:
|
||||||
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
|
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
|
||||||
|
|
||||||
|
|
||||||
func _append_navigation_ribbon(
|
|
||||||
verts: PackedVector2Array,
|
|
||||||
triangles: Array[PackedInt32Array],
|
|
||||||
a: Vector2,
|
|
||||||
b: Vector2,
|
|
||||||
normal: Vector2
|
|
||||||
) -> void:
|
|
||||||
if a.distance_squared_to(b) <= 0.001:
|
|
||||||
return
|
|
||||||
var base: int = verts.size()
|
|
||||||
verts.append_array(PackedVector2Array([
|
|
||||||
a + normal * 4.0, b + normal * 4.0,
|
|
||||||
b - normal * 12.0, a - normal * 12.0,
|
|
||||||
]))
|
|
||||||
triangles.append(PackedInt32Array([base, base + 1, base + 2]))
|
|
||||||
triangles.append(PackedInt32Array([base, base + 2, base + 3]))
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Signal handlers
|
# Signal handlers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3148,31 +3155,12 @@ func _update_area_entry() -> void:
|
|||||||
movables.append(rig)
|
movables.append(rig)
|
||||||
elif node is PropBlock and not (node as PropBlock).freeze:
|
elif node is PropBlock and not (node as PropBlock).freeze:
|
||||||
movables.append(node)
|
movables.append(node)
|
||||||
# Build one swept bound per movable before iterating areas. The stage parent
|
|
||||||
# polls before child rigs apply this frame's free-fall displacement, so the
|
|
||||||
# previous->current union prevents fast airborne motion from tunneling
|
|
||||||
# through a trigger between two observed poses.
|
|
||||||
var swept_bounds: Dictionary = {}
|
|
||||||
var live_ids: Dictionary = {}
|
|
||||||
for node: Node2D in movables:
|
|
||||||
var node_id := node.get_instance_id()
|
|
||||||
var current_bounds := STAGE_SELECTION.get_world_aabb(node).abs()
|
|
||||||
var swept := current_bounds
|
|
||||||
if _area_previous_bounds.has(node_id):
|
|
||||||
swept = swept.merge((_area_previous_bounds[node_id] as Rect2).abs())
|
|
||||||
swept_bounds[node_id] = swept
|
|
||||||
_area_previous_bounds[node_id] = current_bounds
|
|
||||||
live_ids[node_id] = true
|
|
||||||
for old_id: int in _area_previous_bounds.keys().duplicate():
|
|
||||||
if not live_ids.has(old_id):
|
|
||||||
_area_previous_bounds.erase(old_id)
|
|
||||||
for area: TriggerArea in areas:
|
for area: TriggerArea in areas:
|
||||||
var area_rect := (area.global_transform * area.get_area_rect()).abs()
|
var area_rect := (area.global_transform * area.get_area_rect()).abs()
|
||||||
for node: Node2D in movables:
|
for node: Node2D in movables:
|
||||||
var point := _movable_point(node)
|
var point := _movable_point(node)
|
||||||
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
|
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
|
||||||
var inside: bool = (swept_bounds.get(
|
var inside: bool = _movable_overlaps_area(node, area_rect)
|
||||||
node.get_instance_id(), Rect2()) as Rect2).intersects(area_rect, true)
|
|
||||||
var was: bool = bool(_area_overlap.get(key, false))
|
var was: bool = bool(_area_overlap.get(key, false))
|
||||||
if inside and not was:
|
if inside and not was:
|
||||||
_area_overlap[key] = true
|
_area_overlap[key] = true
|
||||||
|
|||||||
+6
-267
@@ -240,26 +240,11 @@ const NAV_TARGET_DESIRED_DISTANCE := 12.0
|
|||||||
## How many post-sync physics frames to wait for the nav agent's reachability
|
## How many post-sync physics frames to wait for the nav agent's reachability
|
||||||
## flag before deciding the walk is genuinely off-mesh (latch "direct").
|
## flag before deciding the walk is genuinely off-mesh (latch "direct").
|
||||||
const LATCH_PROBE_MAX_FRAMES := 6
|
const LATCH_PROBE_MAX_FRAMES := 6
|
||||||
const TERRAIN_PROBE_UP := 96.0
|
|
||||||
const TERRAIN_PROBE_DOWN := 700.0
|
|
||||||
const TERRAIN_NORMAL_MIN_Y := -0.15
|
|
||||||
const TERRAIN_SUPPORT_RANGE := 160.0
|
|
||||||
const TERRAIN_MAX_FOOT_SURFACE_DROP := 72.0
|
|
||||||
const TERRAIN_FOOT_GUARD_HEIGHT := 24.0
|
|
||||||
const TERRAIN_CENTER_PROBE_OFFSETS: Array[float] = [-24.0, 0.0, 24.0]
|
|
||||||
const TERRAIN_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0]
|
|
||||||
const TERRAIN_GRAVITY := 1400.0
|
|
||||||
const TERRAIN_MAX_FALL_SPEED := 1800.0
|
|
||||||
const FOOT_BLEND_RESPONSE := 14.0
|
|
||||||
const PELVIS_MAX_OFFSET := 240.0
|
|
||||||
const PELVIS_RESPONSE := 10.0
|
|
||||||
const WALK_CYCLE_DURATION := 0.8
|
|
||||||
## Rig-local anchor for the speech bubble, above the head.
|
## Rig-local anchor for the speech bubble, above the head.
|
||||||
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
|
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
|
||||||
|
|
||||||
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
|
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
|
||||||
const DEBUG_WALK := false
|
const DEBUG_WALK := false
|
||||||
const DEBUG_TERRAIN := false
|
|
||||||
|
|
||||||
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
|
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
|
||||||
const DEBUG_RECOVERY := false
|
const DEBUG_RECOVERY := false
|
||||||
@@ -519,16 +504,6 @@ var _walk_mode_latched: bool = false
|
|||||||
var _walk_latch_probe_frames: int = 0
|
var _walk_latch_probe_frames: int = 0
|
||||||
var _walk_settle_frames: int = 0
|
var _walk_settle_frames: int = 0
|
||||||
var _walk_done: bool = false
|
var _walk_done: bool = false
|
||||||
var _leg_base_markers: Dictionary = {}
|
|
||||||
var _foot_contacts: Dictionary = {
|
|
||||||
"Left_Leg": {"valid": false, "locked": false},
|
|
||||||
"Right_Leg": {"valid": false, "locked": false},
|
|
||||||
}
|
|
||||||
var _terrain_pelvis_offset: float = 0.0
|
|
||||||
var _terrain_vertical_velocity: float = 0.0
|
|
||||||
var _terrain_airborne: bool = true
|
|
||||||
var _terrain_paused_walk_animation: bool = false
|
|
||||||
var _terrain_following_enabled: bool = false
|
|
||||||
|
|
||||||
var _ragdoll_at_rest: bool = false
|
var _ragdoll_at_rest: bool = false
|
||||||
|
|
||||||
@@ -565,12 +540,6 @@ func _ready() -> void:
|
|||||||
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
|
||||||
if _anim_player == null:
|
if _anim_player == null:
|
||||||
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
|
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
|
||||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
||||||
var base := get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D
|
|
||||||
if base == null:
|
|
||||||
push_warning("StickmanRig: missing AnimationTargets/%s_Base." % leg_name)
|
|
||||||
else:
|
|
||||||
_leg_base_markers[leg_name] = base
|
|
||||||
|
|
||||||
_resolve_bend_modifications()
|
_resolve_bend_modifications()
|
||||||
_resolve_limb_bones()
|
_resolve_limb_bones()
|
||||||
@@ -619,9 +588,7 @@ func _ready() -> void:
|
|||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
_track_momentum(delta)
|
_track_momentum(delta)
|
||||||
_update_rest_detection(delta)
|
_update_rest_detection(delta)
|
||||||
_clear_terrain_pelvis_offset()
|
|
||||||
_update_walking(delta)
|
_update_walking(delta)
|
||||||
_update_terrain_following(delta)
|
|
||||||
_settle_walk_markers()
|
_settle_walk_markers()
|
||||||
_update_speech(delta)
|
_update_speech(delta)
|
||||||
_update_runner(delta)
|
_update_runner(delta)
|
||||||
@@ -655,218 +622,6 @@ func _track_momentum(delta: float) -> void:
|
|||||||
_prev_global_pos = global_position
|
_prev_global_pos = global_position
|
||||||
_prev_global_rot = global_rotation
|
_prev_global_rot = global_rotation
|
||||||
|
|
||||||
|
|
||||||
func _clear_terrain_pelvis_offset() -> void:
|
|
||||||
# Terrain height is accumulated into the runtime root while PLAY is active.
|
|
||||||
# This value is only the last frame's correction, not an offset to undo.
|
|
||||||
_terrain_pelvis_offset = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
## Arms the animated terrain controller without teleporting. An unsupported rig
|
|
||||||
## free-falls until its centerline reaches a TerrainBlock support surface.
|
|
||||||
func begin_terrain_fall() -> void:
|
|
||||||
_terrain_following_enabled = true
|
|
||||||
_clear_terrain_pelvis_offset()
|
|
||||||
_terrain_pelvis_offset = 0.0
|
|
||||||
_terrain_vertical_velocity = 0.0
|
|
||||||
_terrain_airborne = true
|
|
||||||
_terrain_paused_walk_animation = false
|
|
||||||
for leg_name: String in _foot_contacts:
|
|
||||||
_foot_contacts[leg_name] = {"valid": false, "locked": false}
|
|
||||||
|
|
||||||
|
|
||||||
## Kept as a compatibility alias for callers introduced during Phase 6.
|
|
||||||
func snap_to_terrain() -> void:
|
|
||||||
begin_terrain_fall()
|
|
||||||
|
|
||||||
|
|
||||||
func set_terrain_following_enabled(enabled: bool) -> void:
|
|
||||||
_terrain_following_enabled = enabled
|
|
||||||
if enabled:
|
|
||||||
return
|
|
||||||
_clear_terrain_pelvis_offset()
|
|
||||||
_terrain_pelvis_offset = 0.0
|
|
||||||
_terrain_vertical_velocity = 0.0
|
|
||||||
_terrain_airborne = false
|
|
||||||
_terrain_paused_walk_animation = false
|
|
||||||
for leg_name: String in _foot_contacts:
|
|
||||||
_foot_contacts[leg_name] = {"valid": false, "locked": false}
|
|
||||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
||||||
var base := _leg_base_markers.get(leg_name) as Marker2D
|
|
||||||
var target := _get_ik_marker(leg_name)
|
|
||||||
if base != null and target != null:
|
|
||||||
target.position = base.position
|
|
||||||
|
|
||||||
|
|
||||||
func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary:
|
|
||||||
var space_state := get_world_2d().direct_space_state
|
|
||||||
if space_state == null:
|
|
||||||
return {"valid": false}
|
|
||||||
var query := PhysicsRayQueryParameters2D.create(
|
|
||||||
world_point + Vector2.UP * TERRAIN_PROBE_UP,
|
|
||||||
world_point + Vector2.DOWN * probe_down)
|
|
||||||
query.collision_mask = 1
|
|
||||||
query.collide_with_areas = false
|
|
||||||
var excluded: Array[RID] = []
|
|
||||||
for proxy: AnimatableBody2D in _collision_bodies.values():
|
|
||||||
if proxy != null and is_instance_valid(proxy):
|
|
||||||
var rid := proxy.get_rid()
|
|
||||||
if rid.is_valid():
|
|
||||||
excluded.append(rid)
|
|
||||||
query.exclude = excluded
|
|
||||||
var hit := space_state.intersect_ray(query)
|
|
||||||
if hit.is_empty() or not (hit.get("collider") is TerrainBlock):
|
|
||||||
return {"valid": false}
|
|
||||||
var normal: Vector2 = hit.get("normal", Vector2.ZERO)
|
|
||||||
if normal.y > TERRAIN_NORMAL_MIN_Y:
|
|
||||||
return {"valid": false}
|
|
||||||
return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal}
|
|
||||||
|
|
||||||
|
|
||||||
## Samples a small horizontal fan and returns the highest valid terrain surface.
|
|
||||||
## This keeps an overlapping ramp authoritative over flat ground underneath and
|
|
||||||
## prevents a single seam/edge miss from dropping the controller for one frame.
|
|
||||||
func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary:
|
|
||||||
var best: Dictionary = {"valid": false}
|
|
||||||
for x_offset: float in offsets:
|
|
||||||
var hit := _probe_terrain_below(world_point + Vector2(x_offset, 0.0))
|
|
||||||
if not bool(hit.get("valid", false)):
|
|
||||||
continue
|
|
||||||
if not bool(best.get("valid", false)) \
|
|
||||||
or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y:
|
|
||||||
best = hit
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
func _stance_weight(leg_name: String) -> float:
|
|
||||||
if not _walking or _anim_player == null:
|
|
||||||
return 1.0
|
|
||||||
var phase := fmod(_anim_player.get_current_animation_position(), WALK_CYCLE_DURATION)
|
|
||||||
# Continuous complementary weights remove the one-frame 0->1 target snap at
|
|
||||||
# each gait handoff while preserving the clip's alternating half-cycle.
|
|
||||||
var right_weight := 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
|
|
||||||
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
|
|
||||||
|
|
||||||
|
|
||||||
func _update_terrain_following(delta: float) -> void:
|
|
||||||
if not _terrain_following_enabled \
|
|
||||||
or state != RigState.ANIMATED or _leg_base_markers.size() != 2:
|
|
||||||
_terrain_pelvis_offset = 0.0
|
|
||||||
return
|
|
||||||
# Terrain following requires the live leg solvers; preserve the old pinned
|
|
||||||
# LEFT idle behavior only when no terrain controller is available.
|
|
||||||
_set_limb_mods_enabled(true)
|
|
||||||
var center_world := to_global(NAV_AGENT_LOCAL_POS)
|
|
||||||
var center_probe := _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
|
|
||||||
var center_delta := INF
|
|
||||||
if bool(center_probe.get("valid", false)):
|
|
||||||
center_delta = (center_probe["world_pos"] as Vector2).y - center_world.y
|
|
||||||
var center_supported := bool(center_probe.get("valid", false)) \
|
|
||||||
and absf(center_delta) <= TERRAIN_SUPPORT_RANGE
|
|
||||||
var center_surface_y := (center_probe["world_pos"] as Vector2).y \
|
|
||||||
if bool(center_probe.get("valid", false)) else INF
|
|
||||||
var solved: Dictionary = {}
|
|
||||||
var debug_samples: Array[String] = []
|
|
||||||
for leg_name: String in ["Left_Leg", "Right_Leg"]:
|
|
||||||
var base := _leg_base_markers[leg_name] as Marker2D
|
|
||||||
var target := _get_ik_marker(leg_name)
|
|
||||||
if base == null or target == null:
|
|
||||||
continue
|
|
||||||
var base_world := base.global_position
|
|
||||||
var probe := _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS)
|
|
||||||
var probe_delta := INF
|
|
||||||
if bool(probe.get("valid", false)):
|
|
||||||
probe_delta = (probe["world_pos"] as Vector2).y - base_world.y
|
|
||||||
# A foot may reach above/below the pelvis support surface, but must not
|
|
||||||
# choose the flat ground far underneath an overlapping ramp.
|
|
||||||
var within_reach := absf(probe_delta) <= PELVIS_MAX_OFFSET
|
|
||||||
if within_reach and center_supported:
|
|
||||||
within_reach = (probe["world_pos"] as Vector2).y \
|
|
||||||
<= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP
|
|
||||||
var stance := _stance_weight(leg_name)
|
|
||||||
debug_samples.append("%s hit=%s dy=%.1f stance=%.1f" % [
|
|
||||||
leg_name, str(bool(probe.get("valid", false))), probe_delta, stance])
|
|
||||||
var contact: Dictionary = _foot_contacts[leg_name]
|
|
||||||
if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach:
|
|
||||||
if not bool(contact.get("locked", false)):
|
|
||||||
contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]}
|
|
||||||
# Refresh a planted contact when a moved/edited terrain surface changes.
|
|
||||||
elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0:
|
|
||||||
contact["world_pos"] = probe["world_pos"]
|
|
||||||
elif stance < 0.5 or not within_reach:
|
|
||||||
contact = {"valid": bool(probe.get("valid", false)), "locked": false}
|
|
||||||
_foot_contacts[leg_name] = contact
|
|
||||||
var floor_world := base_world
|
|
||||||
if bool(contact.get("locked", false)):
|
|
||||||
floor_world = contact["world_pos"]
|
|
||||||
elif bool(probe.get("valid", false)) and within_reach:
|
|
||||||
floor_world = probe["world_pos"]
|
|
||||||
solved[leg_name] = {
|
|
||||||
"base": base_world,
|
|
||||||
"base_marker": base,
|
|
||||||
"floor": floor_world,
|
|
||||||
"surface": probe.get("world_pos", base_world) if within_reach else base_world,
|
|
||||||
"surface_valid": bool(probe.get("valid", false)) and within_reach,
|
|
||||||
"weight": stance,
|
|
||||||
"target": target,
|
|
||||||
}
|
|
||||||
if not center_supported:
|
|
||||||
_terrain_airborne = true
|
|
||||||
if _walking and _anim_player != null and _anim_player.is_playing():
|
|
||||||
_anim_player.pause()
|
|
||||||
_terrain_paused_walk_animation = true
|
|
||||||
_terrain_vertical_velocity = minf(
|
|
||||||
_terrain_vertical_velocity + TERRAIN_GRAVITY * delta,
|
|
||||||
TERRAIN_MAX_FALL_SPEED)
|
|
||||||
global_position.y += _terrain_vertical_velocity * delta
|
|
||||||
_terrain_pelvis_offset = 0.0
|
|
||||||
for leg_name: String in solved:
|
|
||||||
var air_item: Dictionary = solved[leg_name]
|
|
||||||
# Keep the animated leg pose rigidly attached to the falling root.
|
|
||||||
(air_item["target"] as Marker2D).position = \
|
|
||||||
(air_item["base_marker"] as Marker2D).position
|
|
||||||
if DEBUG_TERRAIN and Engine.get_physics_frames() % 30 == 0:
|
|
||||||
print("[terrain] AIR root=", global_position, " center_dy=", center_delta,
|
|
||||||
" walk=", _walking, " ", debug_samples)
|
|
||||||
return
|
|
||||||
var desired_offset := clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET)
|
|
||||||
if _terrain_airborne:
|
|
||||||
# Landing correction is immediate so a fast fall cannot tunnel a leg
|
|
||||||
# through a thin ramp between physics frames.
|
|
||||||
_terrain_pelvis_offset = desired_offset
|
|
||||||
else:
|
|
||||||
var pelvis_blend := 1.0 - exp(-PELVIS_RESPONSE * delta)
|
|
||||||
# Apply a fraction of the remaining error directly to the runtime root.
|
|
||||||
# The correction accumulates, allowing consecutive ramps to climb far
|
|
||||||
# beyond the single-frame compensation range.
|
|
||||||
_terrain_pelvis_offset = lerpf(0.0, desired_offset, pelvis_blend)
|
|
||||||
_terrain_airborne = false
|
|
||||||
_terrain_vertical_velocity = 0.0
|
|
||||||
if _terrain_paused_walk_animation and _walking and _anim_player != null:
|
|
||||||
_anim_player.play()
|
|
||||||
_terrain_paused_walk_animation = false
|
|
||||||
global_position.y += _terrain_pelvis_offset
|
|
||||||
if DEBUG_TERRAIN and Engine.get_physics_frames() % 30 == 0:
|
|
||||||
print("[terrain] GROUND root=", global_position, " offset=", _terrain_pelvis_offset,
|
|
||||||
" center_dy=", center_delta, " walk=", _walking, " ", debug_samples)
|
|
||||||
for leg_name: String in solved:
|
|
||||||
var item: Dictionary = solved[leg_name]
|
|
||||||
# Re-read the animated base after moving the root. Using the pre-pelvis
|
|
||||||
# world point here counter-moved the swing foot and visibly bent the knee.
|
|
||||||
var base_after := (item["base_marker"] as Marker2D).global_position
|
|
||||||
var contact_weight := float(item["weight"])
|
|
||||||
var surface: Vector2 = item["surface"]
|
|
||||||
if bool(item["surface_valid"]):
|
|
||||||
var clearance := surface.y - base_after.y
|
|
||||||
var guard_weight := 1.0 - clampf(
|
|
||||||
clearance / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0)
|
|
||||||
contact_weight = maxf(contact_weight, smoothstep(0.0, 1.0, guard_weight))
|
|
||||||
var wanted := base_after
|
|
||||||
wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, contact_weight)
|
|
||||||
if bool(item["surface_valid"]) and wanted.y > surface.y:
|
|
||||||
wanted.y = surface.y
|
|
||||||
(item["target"] as Marker2D).global_position = wanted
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Per-frame rest detection
|
# Per-frame rest detection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1080,11 +835,6 @@ func initialize_spawn_pose_from_skeleton() -> void:
|
|||||||
var target: Dictionary = pose[marker_name]
|
var target: Dictionary = pose[marker_name]
|
||||||
marker.position = target.get("pos", marker.position)
|
marker.position = target.get("pos", marker.position)
|
||||||
marker.rotation = target.get("rot", marker.rotation)
|
marker.rotation = target.get("rot", marker.rotation)
|
||||||
if marker_name == "Left_Leg" or marker_name == "Right_Leg":
|
|
||||||
var base_marker := get_node_or_null(
|
|
||||||
NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D
|
|
||||||
if base_marker != null:
|
|
||||||
base_marker.position = marker.position
|
|
||||||
_spawn_pose = pose.duplicate(true)
|
_spawn_pose = pose.duplicate(true)
|
||||||
_spawn_limb_rotations.clear()
|
_spawn_limb_rotations.clear()
|
||||||
for joint: String in LIMB_BONE_PATHS:
|
for joint: String in LIMB_BONE_PATHS:
|
||||||
@@ -2422,8 +2172,6 @@ func _update_walking(delta: float) -> void:
|
|||||||
if state != RigState.ANIMATED:
|
if state != RigState.ANIMATED:
|
||||||
_cancel_walking(false)
|
_cancel_walking(false)
|
||||||
return
|
return
|
||||||
if _terrain_following_enabled and _terrain_airborne:
|
|
||||||
return
|
|
||||||
# Defer all nav reads until the map has actually synchronized. An unsynced
|
# Defer all nav reads until the map has actually synchronized. An unsynced
|
||||||
# agent reports an empty, finished path (map iteration id == 0), which would
|
# agent reports an empty, finished path (map iteration id == 0), which would
|
||||||
# otherwise end the walk after a single move_toward step (~5 px).
|
# otherwise end the walk after a single move_toward step (~5 px).
|
||||||
@@ -2459,9 +2207,7 @@ func _update_walking(delta: float) -> void:
|
|||||||
# repath, so checking it directly would leave the path empty forever.
|
# repath, so checking it directly would leave the path empty forever.
|
||||||
var next_feet := _nav_agent.get_next_path_position()
|
var next_feet := _nav_agent.get_next_path_position()
|
||||||
var final_root := _walk_target_feet + FOOT_OFFSET
|
var final_root := _walk_target_feet + FOOT_OFFSET
|
||||||
# Terrain following owns vertical motion. Navigation supplies horizontal
|
var dist_to_final := global_position.distance_to(final_root)
|
||||||
# progress only, so slope elevation cannot fight the ankle/pelvis solve.
|
|
||||||
var dist_to_final := absf(global_position.x - final_root.x)
|
|
||||||
var root_target: Vector2
|
var root_target: Vector2
|
||||||
if _walk_mode == "nav":
|
if _walk_mode == "nav":
|
||||||
# Terminate once the nav agent reports its path complete AND the rig is
|
# Terminate once the nav agent reports its path complete AND the rig is
|
||||||
@@ -2469,24 +2215,24 @@ func _update_walking(delta: float) -> void:
|
|||||||
# feet-space) while the rig's hard arrival radius is 8 px, so chasing the
|
# feet-space) while the rig's hard arrival radius is 8 px, so chasing the
|
||||||
# last stale next-waypoint would oscillate the rig around the 16-px band.
|
# last stale next-waypoint would oscillate the rig around the 16-px band.
|
||||||
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||||||
global_position.x = final_root.x
|
global_position = final_root
|
||||||
_finish_walk("arrive")
|
_finish_walk("arrive")
|
||||||
return
|
return
|
||||||
# Near the destination, ignore the (possibly behind-path) next waypoint
|
# Near the destination, ignore the (possibly behind-path) next waypoint
|
||||||
# and steer straight at the final target so the rig cannot reverse.
|
# and steer straight at the final target so the rig cannot reverse.
|
||||||
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||||||
root_target = Vector2(final_root.x, global_position.y)
|
root_target = final_root
|
||||||
else:
|
else:
|
||||||
root_target = Vector2(next_feet.x + FOOT_OFFSET.x, global_position.y)
|
root_target = next_feet + FOOT_OFFSET
|
||||||
else:
|
else:
|
||||||
# DIRECT branch: an off-mesh waypoint is a supported case — steer
|
# DIRECT branch: an off-mesh waypoint is a supported case — steer
|
||||||
# straight at the clicked point, ignoring the nav mesh.
|
# straight at the clicked point, ignoring the nav mesh.
|
||||||
root_target = Vector2(final_root.x, global_position.y)
|
root_target = final_root
|
||||||
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
|
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
|
||||||
# Unified arrival radius against the FINAL target, in both branches. Snap
|
# Unified arrival radius against the FINAL target, in both branches. Snap
|
||||||
# the residual offset away so the rig lands exactly on the waypoint.
|
# the residual offset away so the rig lands exactly on the waypoint.
|
||||||
if dist_to_final <= ARRIVE_DISTANCE:
|
if dist_to_final <= ARRIVE_DISTANCE:
|
||||||
global_position.x = final_root.x
|
global_position = final_root
|
||||||
_finish_walk("arrive")
|
_finish_walk("arrive")
|
||||||
return
|
return
|
||||||
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
|
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
|
||||||
@@ -2666,18 +2412,12 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
|||||||
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
|
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
|
||||||
_run_cursor = 0
|
_run_cursor = 0
|
||||||
var primary: Array[Dictionary] = []
|
var primary: Array[Dictionary] = []
|
||||||
var takeover_seen := false
|
|
||||||
for action: Dictionary in actions:
|
for action: Dictionary in actions:
|
||||||
var role: int = ACTION_REGISTRY.reactive_role(action)
|
var role: int = ACTION_REGISTRY.reactive_role(action)
|
||||||
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
|
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
|
||||||
_speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
|
_speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
|
||||||
continue
|
continue
|
||||||
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
|
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
|
||||||
# A rule batch such as [ragdoll, recover] is an ordered sequence.
|
|
||||||
# Interrupt the old work once at the first takeover; later takeover
|
|
||||||
# actions in the same batch must not erase earlier batch actions.
|
|
||||||
if not takeover_seen:
|
|
||||||
takeover_seen = true
|
|
||||||
_cancel_current_primary()
|
_cancel_current_primary()
|
||||||
_overlay_speech.clear()
|
_overlay_speech.clear()
|
||||||
_hide_speech()
|
_hide_speech()
|
||||||
@@ -2693,7 +2433,6 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
|||||||
primary.append(action.duplicate(true))
|
primary.append(action.duplicate(true))
|
||||||
if primary.is_empty():
|
if primary.is_empty():
|
||||||
return
|
return
|
||||||
if not takeover_seen:
|
|
||||||
_cancel_current_primary()
|
_cancel_current_primary()
|
||||||
var injected: Array[Dictionary] = []
|
var injected: Array[Dictionary] = []
|
||||||
for action: Dictionary in primary:
|
for action: Dictionary in primary:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bn0a3hoywsidc
|
||||||
Reference in New Issue
Block a user