Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e39fd9287 | ||
|
|
08247eadef |
@@ -403,6 +403,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)`
|
||||||
@@ -689,13 +702,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).
|
||||||
@@ -742,12 +756,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` →
|
||||||
@@ -789,6 +804,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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -329,6 +329,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).
|
||||||
@@ -636,6 +637,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
|
||||||
@@ -655,6 +657,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()
|
||||||
|
|
||||||
|
|
||||||
@@ -715,6 +718,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:
|
||||||
@@ -724,6 +728,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)
|
||||||
@@ -2061,14 +2066,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:
|
||||||
@@ -2076,14 +2082,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:
|
||||||
@@ -2092,6 +2112,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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -3155,12 +3193,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
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user