14 changed files with 624 additions and 1095 deletions
+3 -2
View File
@@ -17,8 +17,9 @@ You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding stan
- **Targeted Edits Only:** Do NOT output full unchanged files. Output only modified functions, concise diffs, or specific node configurations.
- **Static Typing:** Enforce explicit type hints for all signatures and variables.
- **Decoupling:** Follow "call down, signal up".
- **Validation:** Run syntax verification command when finished:
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\stickman"`
- **Validation:** Run syntax verification when finished (resolve `<godot>`/`<project>` per platform — see the "Command-line testing & debug playback" section of `AGENTS.md`):
`<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
+3 -2
View File
@@ -11,9 +11,10 @@ description: "Lean implementation loop: Architect -> Developer -> User Verificat
2. **Phase 2: Implementation & Engine Validation**
- `@developer` updates code and scenes using targeted diffs/snippets.
- `@developer` runs headless syntax verification:
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\project"`
- `@developer` runs headless syntax verification (resolve `<godot>`/`<project>` per platform — see the "Command-line testing & debug playback" section of `AGENTS.md`):
`<godot> --headless --check-only --script "res://path/to/script.gd" --path "<project>"`
- 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)**
- Present the changes to the user to test inside the Godot Editor.
+23 -33
View File
@@ -2,9 +2,24 @@
## Project type
- **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`)
## 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
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
@@ -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
transform, facing, IK markers, and torso rotation. Null-guards + `push_warning` prefixed
`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
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
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).
- `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
`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` /
`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` +
`freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs
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
with `auto_recover = false`; ragdoll/recover remain explicit actions. Signals
PLAY unfreezes props and ragdolls stickmen with `auto_recover = false`. Signals
`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` /
`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
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
hit-tested) carrying a procedural `NavigationPolygon` from exposed upward-surface ribbons and
short step bridges (`_rebake_navigation()`, world-space via `block.transform * p`); a `_nav_dirty`
flag re-bakes once per frame (`_process`) on terrain
hit-tested) carrying a procedural `NavigationPolygon` from per-`TerrainBlock` convex decomposition
(`_rebake_navigation()`, `Geometry2D.decompose_polygon_in_convex` + fan triangulation, world-space
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`
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()`,
then calls `start_queue()`;
auto-ragdolls stickmen — it now sets each rig `auto_recover = false` and calls `start_queue()`;
`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
`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
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.
- **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
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
+4 -15
View File
@@ -510,19 +510,12 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
**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).
- 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).
**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:**
- 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`.
- 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.
@@ -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
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.
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.
**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/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_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/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). |
@@ -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 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 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.
-492
View File
@@ -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
View File
@@ -190,7 +190,7 @@ tracks/1/keys = {
tracks/2/type = "value"
tracks/2/imported = false
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/loop_wrap = true
tracks/2/keys = {
@@ -202,7 +202,7 @@ tracks/2/keys = {
tracks/3/type = "value"
tracks/3/imported = false
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/loop_wrap = true
tracks/3/keys = {
@@ -266,7 +266,7 @@ tracks/1/keys = {
tracks/2/type = "value"
tracks/2/imported = false
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/loop_wrap = true
tracks/2/keys = {
@@ -278,7 +278,7 @@ tracks/2/keys = {
tracks/3/type = "value"
tracks/3/imported = false
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/loop_wrap = true
tracks/3/keys = {
@@ -555,14 +555,6 @@ position = Vector2(110, 390)
[node name="Left_Leg" type="Marker2D" parent="IK_Targets" unique_id=63598929]
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]
position = Vector2(-96, 200)
target_position = Vector2(0, 200)
+296
View File
@@ -0,0 +1,296 @@
# Command-Line `.scene` Loader + PLAY-on-Launch
Status: proposed — awaiting approval before implementation.
## 1. Goal and boundaries
After Phase 5, a tester must be able to launch the standalone Sandbox Stage from the command line with a `.scene` file, have it load that scene, and (optionally) enter PLAY immediately so the scene "just plays" for automated/manual testing.
Four hard requirements:
1. A command-line `.scene` loader.
2. A flag to start "Stickman Studio" in PLAY mode.
3. PLAY mode is invalid unless a `.scene` is specified.
4. On scene-load error, show the user a dialog and default to a new (blank) scene.
This plan targets `res://scenes/sandbox_stage.tscn` (run standalone), its root script `scripts/sandbox_stage.gd`, a new pure parser, and a new headless test. It does **not** change the stickman editor, `main.tscn`, `project.godot`'s `run/main_scene`, or the `.scene` v1.0 JSON schema.
Constraints that shape the design:
- There is currently **no** command-line argument parsing anywhere in the repo.
- `SandboxStage._ready()` ends with `_update_scene_chrome()` then `_check_autosave_recovery()`. Startup integration must slot in there.
- `set_mode(StageMode.PLAY)` (line ~607) writes an autosave via `_save_scene_to_path(AUTOSAVE_PATH, true)` **before** `_enter_play_mode()`. A CLI PLAY transition must not create that autosave.
- `_load_scene_from_path(path, is_recovery=false)` surfaces read failures with a **toast**, not a dialog (line ~1284). The CLI path needs a dialog-bearing variant.
- `StageSceneIO.read_file(path)` returns `{ok, data, error, warnings}`; `error` is a `String`.
- `_show_missing_assets_dialog(paths)` (line ~1383) is the existing `AcceptDialog` pattern to reuse.
- The stage starts as a blank Untitled document (`_scene_name = "Untitled"`, `_current_scene_path = ""`, default camera), `current_mode` starts `StageMode.EDIT`.
- Tests live in `tests/*.gd`, extend `SceneTree`, instantiate `sandbox_stage.tscn`, and drive internals directly (see `tests/test_phase5_file_features.gd`). No test autoload exists.
## 2. Command-line contract
### 2.1 Flags
| Flag | Type | Meaning |
| --- | --- | --- |
| `--play` | boolean | After a successful `--scene` load, enter `StageMode.PLAY`. |
| `--scene <path>` | value (space-separated) | The `.scene` file to load. |
| `--scene=<path>` | value (equals form) | Same, `=` separated. |
A **bare positional** token that is not `--`-prefixed is accepted as a convenience scene path, but **only when `--scene` is absent**. Supplying both `--scene` and a positional path is a parse error (avoid silent ambiguity).
### 2.2 Godot separator mechanics
Godot consumes its own arguments up to a `--` separator; everything **after** `--` is exposed to the script via `OS.get_cmdline_user_args()`. To also run the sandbox stage instead of the project main scene, the stage scene is passed **positionally before** `--`:
```
Godot --path <project> res://scenes/sandbox_stage.tscn -- <user args>
```
`OS.get_cmdline_user_args()` then returns exactly `["--play", "--scene", "/abs/foo.scene"]` (the positional scene path is **not** in the user-args array — it is a Godot argument).
### 2.3 Exact launch commands (macOS binary per project facts)
Primary tester flow — open and PLAY:
```bash
/Applications/Godot.app/Contents/MacOS/Godot \
--path /Users/ryan/Projects/stickman \
res://scenes/sandbox_stage.tscn \
-- --play --scene /Users/ryan/Projects/stickman/stickmen/test.scene
```
Equivalent forms:
```bash
# --scene= form
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play --scene=res://my_playground.scene
# bare positional scene path (no --scene)
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --play /Users/ryan/Projects/stickman/my_playground.scene
# load into EDIT (no PLAY)
.../Godot --path /Users/ryan/Projects/stickman res://scenes/sandbox_stage.tscn -- --scene res://my_playground.scene
```
Windows equivalent (same tokens, project's established console binary):
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --path "C:\Godot4\stickman" res://scenes/sandbox_stage.tscn -- --play --scene "C:\Godot4\stickman\stickmen\test.scene"
```
### 2.4 Path handling
- `res://…`, `user://…`, and absolute paths are passed through unchanged; `StageSceneIO.read_file` / `FileAccess` already handle all three (the stage already stores both `res://` and absolute paths in recents).
- The parser performs **no** scheme normalization. A bare relative path (no scheme, not absolute) is resolved by Godot against the process CWD and is therefore fragile; the documented and recommended forms are `res://` (project-relative) or absolute. This keeps the parser pure and testable.
## 3. Files and classes
| File | Change | Responsibility |
| --- | --- | --- |
| `scripts/cli_scene_launcher.gd` | **new** `class_name CliSceneLauncher`, `extends RefCounted` | Pure argument parser; no scene-tree or OS access. Returns a structured result. |
| `scripts/sandbox_stage.gd` | modified | Startup integration, the CLI load/error-dialog path, the autosave-suppress guard, and the new error dialog in `_build_ui()`. |
| `tests/test_cli_scene.gd` | **new** | Headless parser + startup-integration coverage. |
No changes to `StageSceneIO`, `stickman_editor.tscn`, `main.tscn`, `project.godot`, or the `.scene` v1.0 schema. No authored `.tscn` node changes in `sandbox_stage.tscn` (the error dialog is code-built like the other file dialogs).
### 3.1 `CliSceneLauncher` API contract
```gdscript
class_name CliSceneLauncher
extends RefCounted
static func parse(args: Array) -> Dictionary
# -> { "scene_path": String, "play": bool, "error": String }
# scene_path = "" when no scene requested
# play = false unless --play was present
# error = "" when valid, else a human-readable message
```
Parsing rules (documented so tests assert them):
1. Scan tokens left to right.
2. `--play` → `play = true`.
3. `--scene` → consume the next token as the scene path; missing/empty value → error.
4. `--scene=<path>` → scene path; empty value → error.
5. A non-`--`-prefixed token → positional scene path; a second positional → error.
6. Both `--scene` and a positional path → error (`"Specify the scene with either --scene or a single positional path."`).
7. Any other `--`-prefixed token → error (`"Unknown option: …"`).
8. After the scan: `play == true` with `scene_path == ""` → error (`"--play requires --scene <path>."`).
The parser enforces requirement 3 (PLAY requires a scene) so the stage can treat any `error` uniformly.
### 3.2 `sandbox_stage.gd` additions
```gdscript
var _cli_error_dialog: AcceptDialog = null # code-built in _build_ui()
var _cli_startup: bool = false # scoped suppress of autosave-on-Play
func _handle_cli_startup(args: Array) -> void # entry point from _ready()
func _load_cli_scene(path: String) -> Dictionary # -> { "ok": bool, "error": String }
func _show_cli_error_dialog(message: String) -> void
```
`_load_cli_scene` reuses `StageSceneIO.read_file` + `_apply_scene_data(data, path, false)` (the ordinary clean-load path), but returns the read error instead of toasting, so the caller can surface it in a dialog:
```gdscript
func _load_cli_scene(path: String) -> Dictionary:
var read: Dictionary = STAGE_SCENE_IO.read_file(path)
if not bool(read.get("ok", false)):
return {"ok": false, "error": String(read.get("error", "Could not load scene."))}
if not _apply_scene_data(read["data"], path, false):
return {"ok": false, "error": "Could not apply scene data."}
return {"ok": true, "error": ""}
```
The existing `_load_scene_from_path` toast path is left untouched for interactive Open.
## 4. Startup flow integration into `_ready()`
Replace the final `_check_autosave_recovery()` line in `_ready()` with a single `_handle_cli_startup(OS.get_cmdline_user_args())` call (which internally owns recovery for the no-`--scene` case):
```gdscript
# _ready() tail (current -> new)
_update_scene_chrome()
_check_autosave_recovery() # removed from _ready() body
# becomes:
_update_scene_chrome()
_handle_cli_startup(OS.get_cmdline_user_args())
```
`_handle_cli_startup(args)` flow:
```text
result := CliSceneLauncher.parse(args)
if result.error != "":
_show_cli_error_dialog(result.error)
_check_autosave_recovery() # invalid CLI -> normal blank-session recovery
return # stay EDIT / blank
if result.scene_path.is_empty():
_check_autosave_recovery() # no --scene -> unchanged behavior
return
# Explicit --scene: retire autosave and skip recovery (explicit file intent wins).
if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH)
load := _load_cli_scene(result.scene_path)
if not load.ok:
_show_cli_error_dialog(load.error)
return # blank/Untitled stage remains
if result.play:
_cli_startup = true # suppress the autosave-on-Play write
set_mode(StageMode.PLAY)
_cli_startup = false
```
### 4.1 Ordering with autosave recovery (decision)
When `--scene` is explicitly given, `_check_autosave_recovery()` is **skipped** and the autosave file is **retired (deleted)** before the CLI load. Justification:
- Explicit user intent ("open *this* file, and play it") must win over an offer to recover a previous interrupted session; popping the recovery dialog over a freshly CLI-loaded scene is both wrong and confusing.
- `_apply_scene_data(..., is_recovery=false)` already deletes the autosave on success, but the **failure** path never reaches that line; deleting upfront covers both outcomes so a stale autosave cannot prompt on a later launch.
- When no `--scene` is given (including the invalid `--play`-only case), recovery is unchanged: `_check_autosave_recovery()` still runs, preserving the existing crash-recovery lifecycle for ordinary launches.
### 4.2 `set_mode` autosave guard
```gdscript
# set_mode(), current:
if mode == StageMode.PLAY:
if not _save_scene_to_path(AUTOSAVE_PATH, true): ...
# becomes:
if mode == StageMode.PLAY and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true): ...
```
`_cli_startup` is a scoped boolean set only around the CLI `set_mode(StageMode.PLAY)` call. This prevents the CLI PLAY transition from writing `user://sandbox_autosave.scene` (which would otherwise be newer than its own source file and trigger a false recovery prompt on the next launch). It does not change the interactive Play button path, and it still routes the transition through the public `set_mode()` so mode bookkeeping (`_sync_mode_buttons`, `_apply_mode_frame`, `_refresh_mode_badge`, `mode_changed`) runs exactly as for a manual Play press.
## 5. Error-dialog and fallback-to-new-scene behavior
A new `AcceptDialog` `_cli_error_dialog` is built in `_build_ui()` alongside `_missing_assets_dialog`:
```text
SandboxStage/UI
└── CliErrorDialog (AcceptDialog)
title: "Could Not Load Scene"
name: "CliErrorDialog"
```
`_show_cli_error_dialog(message)` sets `dialog_text = message` and calls `popup_centered()`, mirroring `_show_missing_assets_dialog`. It is a **blocking** dialog (not the transient toast), satisfying requirement 4.
After the dialog is dismissed, the stage is left exactly as a brand-new document: `Untitled`, empty `World`, default camera, `current_mode == EDIT`. No PLAY is entered. This is the "default to a new (blank) scene" guarantee.
The error dialog is also used for the `--play`-without-`--scene` case (requirement 3), with the parser's error text (e.g. `"--play requires --scene <path>."`).
## 6. `--play` validity rules
| CLI input | Behavior |
| --- | --- |
| `--scene <valid>` | Load the scene, remain EDIT. Title bar shows the loaded scene. |
| `--play --scene <valid>` | Load the scene, then `set_mode(StageMode.PLAY)` (autosave suppressed). |
| `--play` (no `--scene`) | Parser error → `CliErrorDialog`, remain EDIT/blank. **Never** auto-enter PLAY. |
| `--scene <missing/corrupt>` | `CliErrorDialog` with the read error, remain EDIT/blank. |
| unknown flag / two positional paths / `--scene`+positional | Parser error → `CliErrorDialog`, remain EDIT/blank. |
## 7. Dirty / recent state
A CLI load uses the **normal clean-load** semantics (`_apply_scene_data(..., is_recovery=false)`): it adopts `_current_scene_path`, clears `_dirty`, adds the path to recents (`_add_recent_scene`), removes the autosave, and updates the title. This is what makes the title bar show `Scene Name — <file>.scene — Stickman Studio` and keeps the Open Recent menu consistent with the launched file. No special "read-only" or "dirty-on-load" CLI state is introduced.
## 8. Verification strategy
### 8.1 Headless test — `tests/test_cli_scene.gd`
Run with the project's established headless form (macOS):
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/ryan/Projects/stickman --script res://tests/test_cli_scene.gd
```
Required assertions:
1. **Parser (pure, no scene tree):** `CliSceneLauncher.parse([])` → `{scene_path:"", play:false, error:""}`; `["--play","--scene","res://x.scene"]` → `{scene_path:"res://x.scene", play:true, error:""}`; `["--scene=res://x.scene"]` → scene path set; bare positional `["/abs/x.scene","--play"]` → scene path + play; `["--play"]` → error; `["--scene"]` (missing value) → error; `["--bogus"]` → error; two positionals → error; `--scene` + positional → error.
2. **Load:** instantiate `sandbox_stage.tscn`, write a small valid `.scene` fixture to `user://`, call `_handle_cli_startup(["--scene", fixture])`, assert `_current_scene_path == fixture`, `World` child count matches, `_dirty == false`, `_recent_scenes[0] == fixture`, title contains the scene basename, and the autosave file is absent.
3. **PLAY:** `_handle_cli_startup(["--play","--scene", fixture])` → `current_mode == StageMode.PLAY` and `user://sandbox_autosave.scene` does **not** exist afterward (autosave-on-Play suppressed).
4. **Error → blank:** `_handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"])` → `_cli_error_dialog` visible with non-empty `dialog_text`, `World.get_child_count() == 0`, `current_mode == StageMode.EDIT`.
5. **`--play` without scene:** `_handle_cli_startup(["--play"])` → `_cli_error_dialog` visible, `current_mode == StageMode.EDIT`, never PLAY.
6. **Recovery skipped:** pre-write a valid `user://sandbox_autosave.scene` (autosave metadata), call `_handle_cli_startup(["--scene", fixture])`, assert the autosave file is removed and no `AutosaveRecoveryDialog` was created.
Note the repo gotcha (`docs/tech_debt_and_optimizations.md` #22): a newly added `class_name CliSceneLauncher` is not resolvable by `--check-only --script` until an `--editor` pass regenerates `.godot/global_script_class_cache.cfg`. Run once first:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --editor --path /Users/ryan/Projects/stickman --quit
```
Then a parse check:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --check-only --script res://scripts/cli_scene_launcher.gd --path /Users/ryan/Projects/stickman --quit
```
After the focused suite passes, re-run every existing script under `tests/` using its documented headless command and report suite names/results.
### 8.2 Manual Godot Editor verification
Run the exact launch commands from §2.3 in a terminal (not F6) and confirm:
- `--scene res://<file>.scene` opens the scene in EDIT with the correct title and Open Recent entry.
- `--play --scene res://<file>.scene` opens and immediately plays; the Play mode badge shows `▶️ SIMULATING`; no recovery prompt appears on the following ordinary launch.
- `--play` alone shows the "Could Not Load Scene" dialog with `"--play requires --scene <path>."`, leaves a blank Untitled stage, and does not enter PLAY.
- `--scene /nonexistent.scene` shows the dialog with the read error and leaves a blank Untitled stage.
- A pre-existing `user://sandbox_autosave.scene` is removed when launching with `--scene` (explicit file intent overrides recovery).
## 9. Approval gate: decisions to accept before implementation
The architect recommends approving these as a set:
1. **Flag syntax** — `--play` (boolean) and `--scene <path>` (with `--scene=<path>` and a single bare-positional convenience), parsed from `OS.get_cmdline_user_args()`; the parser enforces "PLAY requires a scene" as an error. Paths must be `res://`/`user://`/absolute (no scheme guessing).
2. **Autosave policy** — an explicit `--scene` skips `_check_autosave_recovery()` and retires the autosave file; the CLI PLAY transition suppresses the autosave-on-Play write via a scoped `_cli_startup` guard (leaving the interactive Play button untouched).
3. **Failure semantics** — on any CLI parse error or `--scene` load failure, show a blocking `AcceptDialog` (`CliErrorDialog`) and remain on a blank EDIT/Untitled stage; never auto-enter PLAY. A successful CLI load uses normal clean-load semantics (adopts path, clears dirty, updates recents/title).
## 10. Out of scope
- Changes to `main.tscn`, `stickman_editor.tscn`, `project.godot` `run/main_scene`, or the `.scene` v1.0 JSON schema.
- Reusing the CLI loader to launch the stickman **editor** (`.stk`) — this targets the Sandbox Stage only.
- `--headless`-only automation mode, output/logging flags, or a `--quit-after` flag.
- Argument path normalization (scheme guessing), environment-variable overrides, or multiple-scene batch loading.
-190
View File
@@ -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.
+47
View File
@@ -0,0 +1,47 @@
class_name CliSceneLauncher
extends RefCounted
## Pure command-line argument parser for the Sandbox Stage scene loader.
## No scene-tree or OS access, so it is directly unit-testable. Parses
## OS.get_cmdline_user_args() output into a structured {scene_path, play, error}
## result; the caller (SandboxStage) is responsible for the error dialog and
## autosave policy.
static func parse(args: Array) -> Dictionary:
var scene_path := ""
var play := false
var used_scene_flag := false
var used_positional := false
var error := ""
var i := 0
while i < args.size():
var token := String(args[i])
if token == "--play":
play = true
elif token == "--scene":
used_scene_flag = true
if i + 1 >= args.size():
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
var value := String(args[i + 1])
if value.is_empty():
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
scene_path = value
i += 1
elif token.begins_with("--scene="):
used_scene_flag = true
var value := token.substr("--scene=".length())
if value.is_empty():
return {"scene_path": "", "play": play, "error": "--scene requires a path."}
scene_path = value
elif token.begins_with("--"):
return {"scene_path": "", "play": play, "error": "Unknown option: %s" % token}
else:
if used_positional:
return {"scene_path": "", "play": play, "error": "Specify the scene with either --scene or a single positional path."}
used_positional = true
scene_path = token
i += 1
if used_scene_flag and used_positional:
error = "Specify the scene with either --scene or a single positional path."
elif play and scene_path.is_empty():
error = "--play requires --scene <path>."
return {"scene_path": scene_path, "play": play, "error": error}
+1
View File
@@ -0,0 +1 @@
uid://dmoyg7nq46v07
+59 -71
View File
@@ -198,6 +198,8 @@ var _unsaved_changes_dialog: ConfirmationDialog = null
var _rename_scene_dialog: ConfirmationDialog = null
var _scene_name_edit: LineEdit = null
var _missing_assets_dialog: AcceptDialog = null
var _cli_error_dialog: AcceptDialog = null
var _cli_startup: bool = false
var _panning: bool = false
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>"
## and "<rig_id>:<prop_id>".
var _area_overlap: Dictionary = {}
var _area_previous_bounds: Dictionary = {}
var _collision_pairs: Dictionary = {}
## Lightweight toast text + countdown (cleared in _process on expiry).
@@ -423,7 +424,7 @@ func _ready() -> void:
_apply_cursor()
_refresh_status()
_update_scene_chrome()
_check_autosave_recovery()
_handle_cli_startup(OS.get_cmdline_user_args())
func _process(delta: float) -> void:
@@ -608,7 +609,7 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
func set_mode(mode: StageMode) -> void:
if mode == current_mode:
return
if mode == StageMode.PLAY:
if mode == StageMode.PLAY and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.")
current_mode = mode
@@ -635,7 +636,6 @@ func _enter_edit_shared() -> void:
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
rig.clear_reactive_actions()
# 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)
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
@@ -716,7 +715,6 @@ func _enter_play_mode() -> void:
_set_direct_controls_visible(false)
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
@@ -726,7 +724,6 @@ func _enter_play_mode() -> void:
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.auto_recover = false
rig.begin_terrain_fall()
rig.start_queue()
rig_count += 1
_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()
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:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
_request_pending_file_action(PendingFileAction.QUIT)
@@ -1911,6 +1946,11 @@ func _build_ui() -> void:
_missing_assets_dialog.title = "Missing Scene Assets"
_missing_assets_dialog.min_size = Vector2i(560, 0)
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.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")
@@ -2021,15 +2061,14 @@ func _build_navigation() -> void:
_rebake_navigation()
## Rebuild navigation from exposed top edges only. Each qualifying terrain edge
## becomes a shallow ribbon just above its collision surface, so agents route
## over floors/ramps rather than through solid terrain polygons.
## Rebuild the navigation mesh from every TerrainBlock child of World: each
## block's world-space polygon is convex-decomposed and fan-triangulated into
## one manual NavigationPolygon.
func _rebake_navigation() -> void:
if _nav_region == null or not is_instance_valid(_nav_region):
return
var verts := PackedVector2Array()
var triangles: Array[PackedInt32Array] = []
var surfaces: Array[Dictionary] = []
for child: Node in _world.get_children():
var block := child as TerrainBlock
if block == null:
@@ -2037,28 +2076,14 @@ func _rebake_navigation() -> void:
var pts := PackedVector2Array()
for p: Vector2 in block.polygon_points:
pts.append(block.transform * p)
for i: int in pts.size():
var a := pts[i]
var b := pts[(i + 1) % pts.size()]
var edge := b - a
if edge.length_squared() <= 0.001:
continue
# TerrainUtils stores clockwise polygons; this right normal is
# outward, so only its upward-facing edges are walkable.
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 pieces := Geometry2D.decompose_polygon_in_convex(pts)
if pieces.is_empty():
pieces = [pts]
for piece: PackedVector2Array in pieces:
var base: int = verts.size()
verts.append_array(piece)
for i: int in range(1, piece.size() - 1):
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
var poly := NavigationPolygon.new()
poly.set_vertices(verts)
for tri: PackedInt32Array in triangles:
@@ -2067,24 +2092,6 @@ func _rebake_navigation() -> void:
if DEBUG_STAGE:
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
# ---------------------------------------------------------------------------
@@ -3148,31 +3155,12 @@ func _update_area_entry() -> void:
movables.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
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:
var area_rect := (area.global_transform * area.get_area_rect()).abs()
for node: Node2D in movables:
var point := _movable_point(node)
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var inside: bool = (swept_bounds.get(
node.get_instance_id(), Rect2()) as Rect2).intersects(area_rect, true)
var inside: bool = _movable_overlaps_area(node, area_rect)
var was: bool = bool(_area_overlap.get(key, false))
if inside and not was:
_area_overlap[key] = true
+6 -267
View File
@@ -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
## flag before deciding the walk is genuinely off-mesh (latch "direct").
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.
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
const DEBUG_TERRAIN := false
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
const DEBUG_RECOVERY := false
@@ -519,16 +504,6 @@ var _walk_mode_latched: bool = false
var _walk_latch_probe_frames: int = 0
var _walk_settle_frames: int = 0
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
@@ -565,12 +540,6 @@ func _ready() -> void:
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
if _anim_player == null:
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_limb_bones()
@@ -619,9 +588,7 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
_clear_terrain_pelvis_offset()
_update_walking(delta)
_update_terrain_following(delta)
_settle_walk_markers()
_update_speech(delta)
_update_runner(delta)
@@ -655,218 +622,6 @@ func _track_momentum(delta: float) -> void:
_prev_global_pos = global_position
_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
# ---------------------------------------------------------------------------
@@ -1080,11 +835,6 @@ func initialize_spawn_pose_from_skeleton() -> void:
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
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_limb_rotations.clear()
for joint: String in LIMB_BONE_PATHS:
@@ -2422,8 +2172,6 @@ func _update_walking(delta: float) -> void:
if state != RigState.ANIMATED:
_cancel_walking(false)
return
if _terrain_following_enabled and _terrain_airborne:
return
# Defer all nav reads until the map has actually synchronized. An unsynced
# agent reports an empty, finished path (map iteration id == 0), which would
# 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.
var next_feet := _nav_agent.get_next_path_position()
var final_root := _walk_target_feet + FOOT_OFFSET
# Terrain following owns vertical motion. Navigation supplies horizontal
# progress only, so slope elevation cannot fight the ankle/pelvis solve.
var dist_to_final := absf(global_position.x - final_root.x)
var dist_to_final := global_position.distance_to(final_root)
var root_target: Vector2
if _walk_mode == "nav":
# 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
# 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:
global_position.x = final_root.x
global_position = final_root
_finish_walk("arrive")
return
# Near the destination, ignore the (possibly behind-path) next waypoint
# and steer straight at the final target so the rig cannot reverse.
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
root_target = Vector2(final_root.x, global_position.y)
root_target = final_root
else:
root_target = Vector2(next_feet.x + FOOT_OFFSET.x, global_position.y)
root_target = next_feet + FOOT_OFFSET
else:
# DIRECT branch: an off-mesh waypoint is a supported case — steer
# 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)
# Unified arrival radius against the FINAL target, in both branches. Snap
# the residual offset away so the rig lands exactly on the waypoint.
if dist_to_final <= ARRIVE_DISTANCE:
global_position.x = final_root.x
global_position = final_root
_finish_walk("arrive")
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" % [
@@ -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_cursor = 0
var primary: Array[Dictionary] = []
var takeover_seen := false
for action: Dictionary in actions:
var role: int = ACTION_REGISTRY.reactive_role(action)
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
_speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
continue
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()
_overlay_speech.clear()
_hide_speech()
@@ -2693,7 +2433,6 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
primary.append(action.duplicate(true))
if primary.is_empty():
return
if not takeover_seen:
_cancel_current_primary()
var injected: Array[Dictionary] = []
for action: Dictionary in primary:
+166
View File
@@ -0,0 +1,166 @@
extends SceneTree
const IO := preload("res://scripts/stage_scene_io.gd")
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const FIXTURE_PATH := "user://cli_fixture.scene"
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_cleanup()
_test_parser()
await _test_load_into_edit()
await _test_play_mode()
await _test_missing_scene_error()
await _test_play_without_scene()
await _test_recovery_skipped()
_cleanup()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _expect(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
push_error("CLI scene test failed: " + message)
func _cleanup() -> void:
if FileAccess.file_exists(FIXTURE_PATH):
DirAccess.remove_absolute(FIXTURE_PATH)
if FileAccess.file_exists("user://sandbox_autosave.scene"):
DirAccess.remove_absolute("user://sandbox_autosave.scene")
func _write_fixture() -> void:
var fixture := {
"version": "1.0",
"scene_name": "CLI Fixture",
"camera": {"position": {"x": 0.0, "y": -400.0}, "zoom": 0.5},
"objects": [{
"file_id": "terrain_1",
"type": "terrain",
"position": {"x": 20.0, "y": 30.0},
"rotation": 0.0,
"asset_id": "ground",
"scale": {"x": 1.0, "y": 1.0},
}],
"event_rules": [],
"metadata": {},
}
IO.write_file_safe(FIXTURE_PATH, fixture)
func _make_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
return stage
func _test_parser() -> void:
var empty: Dictionary = CliSceneLauncher.parse([])
_expect(String(empty["scene_path"]) == "" and not bool(empty["play"]) and String(empty["error"]) == "", "empty args parse clean")
var full: Dictionary = CliSceneLauncher.parse(["--play", "--scene", "res://x.scene"])
_expect(String(full["scene_path"]) == "res://x.scene" and bool(full["play"]) and String(full["error"]) == "", "--play --scene sets both")
var eq: Dictionary = CliSceneLauncher.parse(["--scene=res://x.scene"])
_expect(String(eq["scene_path"]) == "res://x.scene" and not bool(eq["play"]), "--scene= form sets path")
var positional: Dictionary = CliSceneLauncher.parse(["/abs/x.scene", "--play"])
_expect(String(positional["scene_path"]) == "/abs/x.scene" and bool(positional["play"]), "bare positional plus --play")
var play_only: Dictionary = CliSceneLauncher.parse(["--play"])
_expect(not String(play_only["error"]).is_empty(), "--play alone is an error")
var missing_value: Dictionary = CliSceneLauncher.parse(["--scene"])
_expect(not String(missing_value["error"]).is_empty(), "--scene without value is an error")
var unknown: Dictionary = CliSceneLauncher.parse(["--bogus"])
_expect(not String(unknown["error"]).is_empty(), "unknown option is an error")
var two_pos: Dictionary = CliSceneLauncher.parse(["a.scene", "b.scene"])
_expect(not String(two_pos["error"]).is_empty(), "two positionals are an error")
var flag_and_pos: Dictionary = CliSceneLauncher.parse(["--scene", "a.scene", "b.scene"])
_expect(not String(flag_and_pos["error"]).is_empty(), "--scene plus positional is an error")
func _test_load_into_edit() -> void:
_write_fixture()
var stage: Node2D = _make_stage()
await process_frame
stage._handle_cli_startup(["--scene", FIXTURE_PATH])
_expect(stage._current_scene_path == FIXTURE_PATH, "load adopts the scene path")
_expect(stage._world.get_child_count() == 1, "load reconstructs one object")
_expect(not stage._dirty, "load clears dirty")
_expect(stage._recent_scenes.size() > 0 and stage._recent_scenes[0] == FIXTURE_PATH, "load updates Open Recent")
_expect("cli_fixture.scene" in stage.get_window().title, "title shows the loaded scene file")
_expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "clean load leaves no autosave")
stage.queue_free()
await process_frame
func _test_play_mode() -> void:
_write_fixture()
var stage: Node2D = _make_stage()
await process_frame
stage._handle_cli_startup(["--play", "--scene", FIXTURE_PATH])
_expect(stage.current_mode == stage.StageMode.PLAY, "--play enters PLAY mode")
_expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "CLI PLAY suppresses the autosave write")
stage.queue_free()
await process_frame
func _test_missing_scene_error() -> void:
var stage: Node2D = _make_stage()
await process_frame
stage._handle_cli_startup(["--scene", "user://definitely_missing_cli.scene"])
_expect(stage._cli_error_dialog != null and stage._cli_error_dialog.visible, "missing scene shows the error dialog")
_expect(not stage._cli_error_dialog.dialog_text.is_empty(), "missing scene error has dialog text")
_expect(stage._world.get_child_count() == 0, "missing scene leaves a blank stage")
_expect(stage.current_mode == stage.StageMode.EDIT, "missing scene stays in EDIT")
stage._cli_error_dialog.hide()
stage.queue_free()
await process_frame
func _test_play_without_scene() -> void:
var stage: Node2D = _make_stage()
await process_frame
stage._handle_cli_startup(["--play"])
_expect(stage._cli_error_dialog != null and stage._cli_error_dialog.visible, "--play without scene shows the error dialog")
_expect(stage._cli_error_dialog.dialog_text == "--play requires --scene <path>.", "--play error text matches the parser contract")
_expect(stage.current_mode == stage.StageMode.EDIT, "--play without scene never enters PLAY")
stage._cli_error_dialog.hide()
stage.queue_free()
await process_frame
func _test_recovery_skipped() -> void:
var stage: Node2D = _make_stage()
await process_frame
_write_fixture()
var autosave := {
"version": "1.0",
"scene_name": "Stale Autosave",
"objects": [],
"event_rules": [],
"metadata": {"autosave": true, "source_path": "", "modified_unix": Time.get_unix_time_from_system()},
}
IO.write_file_safe(stage.AUTOSAVE_PATH, autosave)
_expect(FileAccess.file_exists(stage.AUTOSAVE_PATH), "autosave fixture pre-written")
stage._handle_cli_startup(["--scene", FIXTURE_PATH])
_expect(not FileAccess.file_exists(stage.AUTOSAVE_PATH), "explicit --scene retires the autosave")
var ui: Node = stage.get_node(NodePath("UI"))
_expect(ui.get_node_or_null(NodePath("AutosaveRecoveryDialog")) == null, "no recovery dialog is created for explicit --scene")
_expect(stage._current_scene_path == FIXTURE_PATH, "explicit --scene loads the requested scene")
stage.queue_free()
await process_frame
+1
View File
@@ -0,0 +1 @@
uid://bn0a3hoywsidc