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