feat: Implement Phase 6a Airborne Mechanics and Landing Absorption

- Added new scene file `test_fall.scene` for testing airborne dynamics.
- Created architecture proposal in `PHASE_6a_AIRBORNE_LANDING.md` detailing airborne momentum, procedural air pose, and landing impact absorption mechanics.
- Developed specifications in `phase_6a_airborne_landing.md` outlining implementation decisions, targets, and static contracts for the airborne system.
- Introduced focused regression tests in `test_phase6a_airborne_landing.gd` to validate airborne pose, momentum, landing timeline, and queue behavior.
- Ensured all tests pass with 100% assertion success.
This commit is contained in:
2026-09-17 19:09:54 -04:00
parent 3e39fd9287
commit 2071ee03b7
10 changed files with 1871 additions and 45 deletions
+6 -4
View File
@@ -1,5 +1,5 @@
name = "architect" name = "architect"
description = "Defines system requirements and technical specifications for Godot features." description = "Defines system requirements, evaluates architectural choices, and suggests technical improvements for Godot features."
model = "gpt-5.6-sol" model = "gpt-5.6-sol"
model_reasoning_effort = "high" model_reasoning_effort = "high"
sandbox_mode = "read-only" sandbox_mode = "read-only"
@@ -10,15 +10,17 @@ You are the Lead Systems Architect. Refer to `AGENTS.md` for project directory s
## Responsibilities ## Responsibilities
1. **Analyze:** Read existing code patterns before designing. 1. **Analyze:** Read existing code patterns before designing.
2. **Design:** Define API payload shapes, GDScript type hints, and signal flows. 2. **Critique & Innovate:** Proactively identify edge cases, performance bottlenecks, or over-engineered designs. Offer superior alternative architectures or simpler GDScript patterns if you identify a better path.
3. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`. 3. **Design:** Define API payload shapes, GDScript type hints, and signal flows.
4. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`.
## Workflow ## Workflow
1. **Scope & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous. 1. **Scope, Critique & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous. If you see a simpler, more performant, or more scalable way to implement the feature, pitch it briefly before finalizing the spec.
2. **Spec Creation:** Output a minimal specification containing: 2. **Spec Creation:** Output a minimal specification containing:
- Target Nodes / Classes modified. - Target Nodes / Classes modified.
- Signal interfaces & static type contracts. - Signal interfaces & static type contracts.
- Layout/Theme updates required. - Layout/Theme updates required.
- **Architectural Suggestions & Alternatives:** (Optional/Required if applicable) Highlight alternative approaches, potential pitfalls, or future-proofing ideas that improve the feature.
3. **Handoff:** Save to `specs/[feature-name].md` and stop for user approval. 3. **Handoff:** Save to `specs/[feature-name].md` and stop for user approval.
""" """
+13
View File
@@ -416,6 +416,19 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
weights, world-space stance locks, and a predictive 24 px foot-penetration guard. It also filters 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` lower floors beneath overlapping ramps and applies centerline pelvis compensation. `DEBUG_TERRAIN`
is `false` in the shipped build; temporary `[terrain]` traces are opt-in. is `false` in the shipped build; temporary `[terrain]` traces are opt-in.
- **Phase 6a airborne/landing:** extends the PLAY-only terrain controller with internal
`TerrainMotion.AIRBORNE` and `LANDING_IMPACT` phases while keeping `RigState.ANIMATED`. Ledge
departures inherit signed grounded X velocity and apply exponential drag (`AIR_DRAG_X = 0.75`);
airborne horizontal travel uses a swept query against each of the ten synchronized collision
proxies and stops at the first safe fraction. The captured live hand/foot pose receives additive
flail, with hands reaching canonical local `+X` before lifting; the LEFT root mirror supplies the
opposite screen-facing direction. Falls below `HARD_LANDING_MIN_FALL_HEIGHT = 80` px land softly;
fall distance through 900 px scales torso compression `32..160` px and downward hand drop
`270..520` px across the 0.08 s compress + 0.22 s recover timeline. Both feet lock to
independent terrain samples, the upright root remains unrotated, and `LANDING_IMPACT` blocks
primary queue progression while overlay speech continues. Direct ragdoll and terrain disable
cancel the procedural pose cleanly. `tests/test_phase6a_airborne_landing.gd` passes 24/24
assertions; the implementation is user-verified (2026-09-17).
- **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)`
+2
View File
@@ -37,6 +37,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 25 | **Contact direct-state timing fallback** — `PropBlock` may receive `body_entered` before a matching direct-state contact row is available. | Low | Open | `_on_body_entered()` uses the documented center-direction / prop-position fallback when the row is absent, so the signal remains safe but the normal and position are less precise on that edge. (2026-09-12) | | 25 | **Contact direct-state timing fallback** — `PropBlock` may receive `body_entered` before a matching direct-state contact row is available. | Low | Open | `_on_body_entered()` uses the documented center-direction / prop-position fallback when the row is absent, so the signal remains safe but the normal and position are less precise on that edge. (2026-09-12) |
| 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) | | 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) |
| 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) | | 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) |
| 28 | **Airborne collision sweep cost scales per proxy** — Phase 6a tests the horizontal movement against each of the rig's ten synchronized collision proxies every airborne physics frame. | Low | Open | The per-proxy `PhysicsShapeQueryParameters2D.cast_motion()` checks reuse the existing proxy shapes and avoid a second body-size approximation; this is acceptable for the current sandbox scale. With many simultaneously falling rigs, the cost grows with `rigs × 10` casts per physics frame. Consider a combined query shape or broadphase candidate filter if airborne crowds become a target. (2026-09-17) |
--- ---
@@ -79,6 +80,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. | | 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. |
| 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). | | 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). |
| 2026-09-12 | Phase 4.5 collision bodies verified: ten explicitly synchronized `AnimatableBody2D` proxies replace the old feet-point stickman collision poll; `PropBlock` supplies world-space contact data with shape-cast CCD; stage routing preserves rig→prop rules with per-part pair debouncing; bounded quadrant-aware flinches and PLAY→DIRECT authored-pose restoration were verified. `tests/test_phase45_collision_bodies.gd` passed 223 assertions; Phase 4b (53), Phase 4c (19), and Phase 3c (96) remained green. Reconciled #1/#5 and retained #25–#27 as intentional implementation limitations. | | 2026-09-12 | Phase 4.5 collision bodies verified: ten explicitly synchronized `AnimatableBody2D` proxies replace the old feet-point stickman collision poll; `PropBlock` supplies world-space contact data with shape-cast CCD; stage routing preserves rig→prop rules with per-part pair debouncing; bounded quadrant-aware flinches and PLAY→DIRECT authored-pose restoration were verified. `tests/test_phase45_collision_bodies.gd` passed 223 assertions; Phase 4b (53), Phase 4c (19), and Phase 3c (96) remained green. Reconciled #1/#5 and retained #25–#27 as intentional implementation limitations. |
| 2026-09-17 | Phase 6a (Airborne Momentum, Procedural Air Pose, and Landing Absorption) implemented and user-verified. `StickmanRig` now carries signed ledge momentum with tuned exponential drag (`AIR_DRAG_X = 0.75`), swept horizontal proxy collision, additive free-fall pose with forward-before-lift arms, and height-driven landing absorption: falls below 80 px are soft; 80..900 px maps to 32..160 px torso compression and 270..520 px hand drop over 0.30 s. Independent foot contacts preserve ramps/stairs, while `LANDING_IMPACT` blocks primary queue progression and direct ragdoll remains an immediate preemption. `tests/test_phase6a_airborne_landing.gd` passed 24 assertions; `tests/test_phase4b_terrain.gd` passed 8 assertions. Logged #28 (per-proxy airborne sweep scaling). |
--- ---
+540 -8
View File
@@ -3,10 +3,10 @@
"scene_name": "Untitled", "scene_name": "Untitled",
"camera": { "camera": {
"position": { "position": {
"x": 1070.22937011719, "x": 1074.43005371094,
"y": -54.6092796325684 "y": 2419.70385742188
}, },
"zoom": 0.375657439231873 "zoom": 0.17715610563755
}, },
"objects": [ "objects": [
{ {
@@ -205,8 +205,8 @@
{ {
"type": "walk_to", "type": "walk_to",
"target": { "target": {
"x": 3112.55639648438, "x": -1861.99194335938,
"y": 493.14306640625 "y": 434.2099609375
} }
} }
] ]
@@ -439,8 +439,8 @@
"file_id": "area_1", "file_id": "area_1",
"type": "area", "type": "area",
"position": { "position": {
"x": 1650.52270507813, "x": 3245.060546875,
"y": -186.824584960938 "y": -1320.83642578125
}, },
"rotation": 0.0, "rotation": 0.0,
"scale": { "scale": {
@@ -451,6 +451,538 @@
"x": 96.0, "x": 96.0,
"y": 96.0 "y": 96.0
} }
},
{
"file_id": "terrain_30",
"type": "terrain",
"position": {
"x": -488.163452148438,
"y": -1807.93115234375
},
"rotation": 0.0,
"asset_id": "ramp",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_31",
"type": "terrain",
"position": {
"x": 204.134155273438,
"y": -1419.78088378906
},
"rotation": 0.0,
"asset_id": "ramp",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_32",
"type": "terrain",
"position": {
"x": 340.276489257813,
"y": -1889.03723144531
},
"rotation": 0.0,
"asset_id": "ramp",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_33",
"type": "terrain",
"position": {
"x": 1620.59313964844,
"y": -130.774658203125
},
"rotation": 74.9999986719706,
"asset_id": "ramp",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_34",
"type": "terrain",
"position": {
"x": 1753.83874511719,
"y": -12.01220703125
},
"rotation": 74.9999986719706,
"asset_id": "ramp",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_35",
"type": "terrain",
"position": {
"x": 1910.25769042969,
"y": 118.336669921875
},
"rotation": 74.9999986719706,
"asset_id": "ramp",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_36",
"type": "terrain",
"position": {
"x": -5376.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_37",
"type": "terrain",
"position": {
"x": -5184.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_38",
"type": "terrain",
"position": {
"x": -4992.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_39",
"type": "terrain",
"position": {
"x": -4800.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_40",
"type": "terrain",
"position": {
"x": -4608.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_41",
"type": "terrain",
"position": {
"x": -4416.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_42",
"type": "terrain",
"position": {
"x": -4224.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_43",
"type": "terrain",
"position": {
"x": -4032.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_44",
"type": "terrain",
"position": {
"x": -3840.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_45",
"type": "terrain",
"position": {
"x": -3648.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_46",
"type": "terrain",
"position": {
"x": -3456.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_47",
"type": "terrain",
"position": {
"x": -3264.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_48",
"type": "terrain",
"position": {
"x": -3072.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_49",
"type": "terrain",
"position": {
"x": -2880.0,
"y": 5600.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_50",
"type": "terrain",
"position": {
"x": -4054.02685546875,
"y": 1123.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_51",
"type": "terrain",
"position": {
"x": -3862.02685546875,
"y": 1123.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_52",
"type": "terrain",
"position": {
"x": -3670.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_53",
"type": "terrain",
"position": {
"x": -3478.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_54",
"type": "terrain",
"position": {
"x": -3286.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_55",
"type": "terrain",
"position": {
"x": -3094.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_56",
"type": "terrain",
"position": {
"x": -2902.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_57",
"type": "terrain",
"position": {
"x": -2710.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_58",
"type": "terrain",
"position": {
"x": -2518.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_59",
"type": "terrain",
"position": {
"x": -2326.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_60",
"type": "terrain",
"position": {
"x": -2134.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_61",
"type": "terrain",
"position": {
"x": -1942.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_62",
"type": "terrain",
"position": {
"x": -1750.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_63",
"type": "terrain",
"position": {
"x": -1558.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_64",
"type": "terrain",
"position": {
"x": -1366.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_65",
"type": "terrain",
"position": {
"x": -1174.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_66",
"type": "terrain",
"position": {
"x": -982.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_67",
"type": "terrain",
"position": {
"x": -790.02685546875,
"y": 1091.7216796875
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
} }
], ],
"event_rules": [ "event_rules": [
@@ -485,7 +1017,7 @@
], ],
"metadata": { "metadata": {
"created_unix": 1789479607, "created_unix": 1789479607,
"modified_unix": 1789529735.774, "modified_unix": 1789616216.822,
"autosave": false, "autosave": false,
"source_path": "" "source_path": ""
} }
+407
View File
@@ -0,0 +1,407 @@
{
"version": "1.0",
"scene_name": "Untitled",
"camera": {
"position": {
"x": 976.030700683594,
"y": 725.191955566406
},
"zoom": 0.282236993312836
},
"objects": [
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -960.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -768.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -576.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -384.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": -192.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": 0.0,
"y": -288.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": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": 384.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": 576.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -222.0,
"y": -959.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "walk_to",
"target": {
"x": -1302.19653320313,
"y": -305.856567382813
}
},
{
"type": "ragdoll"
}
]
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": 768.0,
"y": 2048.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_11",
"type": "terrain",
"position": {
"x": 960.0,
"y": 2048.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_12",
"type": "terrain",
"position": {
"x": 1152.0,
"y": 2048.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_13",
"type": "terrain",
"position": {
"x": 1344.0,
"y": 2048.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_14",
"type": "terrain",
"position": {
"x": 1536.0,
"y": 2080.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_15",
"type": "terrain",
"position": {
"x": 1728.0,
"y": 2080.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_16",
"type": "terrain",
"position": {
"x": 1920.0,
"y": 2080.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": 2112.0,
"y": 2080.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": -2112.0,
"y": 2016.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": -1920.0,
"y": 2016.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": -1728.0,
"y": 2016.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": 2016.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_22",
"type": "terrain",
"position": {
"x": -1344.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_23",
"type": "terrain",
"position": {
"x": -1152.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_24",
"type": "terrain",
"position": {
"x": -960.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_25",
"type": "terrain",
"position": {
"x": -768.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_26",
"type": "terrain",
"position": {
"x": -576.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
}
],
"event_rules": [],
"metadata": {
"created_unix": 1789666501,
"modified_unix": 1789666824.739,
"autosave": false,
"source_path": ""
}
}
+66
View File
@@ -0,0 +1,66 @@
# Phase 6a Architecture Proposal: Airborne Momentum, Dynamic Air-Pose & Landing Impact Absorption
## 1. Executive Summary
Phase 6a extends the shipped Phase 6 continuous terrain locomotion system by replacing rigid vertical free-falls and instant landings with dynamic, momentum-driven airborne physics. It introduces horizontal air momentum decay, procedural wind-drag pose flailing during fall, and velocity-proportional landing impact compression that temporarily holds action queue execution.
---
## 2. Technical Mechanics & State Integration
### 2.1 Airborne Horizontal Momentum Decay
- **Momentum Snapshot**: Upon detecting a loss of ground support during `ANIMATED` mode, `StickmanRig` caches the last horizontal velocity ($v_x$) from navigation or manual walking.
- **Parabolic Arc**: During free-fall frames, $v_x$ continues to shift root $X$ while applying exponential air drag ($\text{AIR\_DRAG\_X} = 4.0$). Vertical gravity (`TERRAIN_GRAVITY`) accelerates $Y$ down to `TERRAIN_MAX_FALL_SPEED`.
### 2.2 Procedural Air Pose & Wind-Drag Flail
- **Limb Displacement**: As downward velocity ($v_y$) increases, upper limb `IK_Targets` blend away from `AnimationTargets/*_Base` markers, pulling hands above the head to simulate wind resistance.
- **High-Frequency Flail**: Layer anti-phased sine-wave offsets ($\sim 12\text{ Hz}$) onto hand and foot IK targets, scaling amplitude with $v_y$ to create a realistic airborne fluttering effect.
### 2.3 Landing Impact Compression & Queue Hold
- **Velocity Capture**: On `_probe_ground_y()` contact detection, record the final impact fall speed ($v_{\text{impact}}$).
- **Impact Absorption**: If $v_{\text{impact}} \ge \text{MIN\_IMPACT\_VELOCITY}$, trigger a 2-phase pelvis squash tween:
1. **Compress**: Rapidly ease pelvis Y downward ($\sim 0.08\text{s}$) into a deep crouch, forcing leg `TwoBoneIK` solvers to flex while feet stay locked to ground contacts.
2. **Recover**: Spring pelvis Y back up to standing terrain height ($\sim 0.22\text{s}$) using a sine-out easing curve.
- **Queue Synchronization**: Set an internal `_is_landing_impact` flag during absorption to pause `action_queue` processing, resuming automatically once standing recovery completes.
---
## 3. Component & API Specification
| Component | Target System | Implementation Details |
| --------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Momentum Tracker** | `StickmanRig._physics_process()` | Snapshot $v_x$ at fall transition; apply $v_x \cdot \Delta t$ with exponential decay to root $X$ during free-fall. |
| **Air Pose Solver** | `StickmanRig` IK Stack | Offset live arm/leg `IK_Targets` dynamically relative to base markers based on $v_y$. |
| **Impact Absorption** | `StickmanRig` Terrain Controller | Run pelvis squash/spring tween on ground contact; hold `action_queue` until complete. |
---
## 4. Verification & Acceptance Criteria
- **Ledge Ejection**: Walking off an elevated ledge projects the rig outward in a parabolic curve rather than dropping straight down.
- **Air Flail**: Arms flex upward and hands/feet flutter rhythmically during high-speed free-fall.
- **Impact Cushion**: Drops exceeding minimum velocity threshold exhibit a visible knee bend and pelvis compression before standing up.
- **Queue Order**: Action queue execution halts during landing impact recovery and resumes cleanly without dropping or skipping pending actions.
## 5. Open Questions and Edge Cases
To ensure the implementation runs smoothly, here are the key edge cases and technical questions your architect should clarify before finalizing Phase 6a:
- **Ragdoll Escalation Threshold**: If the fall velocity ($v_{\text{impact}}$) exceeds a extreme safety limit, should the stickman escalate into a full dynamic ragdoll collapse instead of executing a soft crouch-and-recover animation?
- **Mid-Air Wall Collisions**: How should horizontal air momentum ($v_x$) react if the stickman drifts laterally into a vertical wall or prop before touching the floor? Should horizontal velocity immediately zero out on wall contact to prevent body clipping?
- **Sloped Landing Compression**: How does pelvic squash behave when landing on an angled ramp or stair tread? We need to confirm whether left and right foot IK targets remain independently anchored to unequal terrain elevations while the pelvis squashes downward.
- **Action Queue Preemption**: If a dynamic trigger fires or a user issues a new direct action while the stickman is airborne or mid-landing crouch, should the incoming action cancel the landing recovery sequence or wait at the front of the queue?
- **Custom Pose Overrides**: How should wind-drag flailing blend if the character goes airborne while performing a non-locomotion action (like holding an item or attacking)? Should flailing override all hand keyframes, or only apply during default walk/idle states?
+334 -33
View File
@@ -33,6 +33,10 @@ enum RunnerState { IDLE, EXECUTING }
## Which kind of queued action is currently executing (Phase 3a). ## Which kind of queued action is currently executing (Phase 3a).
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING } enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
## Internal PLAY-only terrain locomotion phase. This deliberately remains
## separate from RigState: a normal fall is still an animated puppet.
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -250,6 +254,49 @@ 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_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0]
const TERRAIN_GRAVITY := 1400.0 const TERRAIN_GRAVITY := 1400.0
const TERRAIN_MAX_FALL_SPEED := 1800.0 const TERRAIN_MAX_FALL_SPEED := 1800.0
## Airborne motion tuning. Distances use rig-space pixels, speeds use px/s, and
## drag/response values use 1/s. AIR_DRAG_X is exponential horizontal damping:
## lower values preserve momentum for a longer, stronger curved arc; higher
## values shorten the arc by settling vx toward zero more quickly.
const AIR_DRAG_X := 0.75
## Falling-speed range (px/s) for the procedural air pose: the pose weight is
## 0 at START_SPEED and 1 at FULL_SPEED, with smooth interpolation between.
const AIR_POSE_START_SPEED := 240.0
const AIR_POSE_FULL_SPEED := 1200.0
## How quickly the air-pose weight responds to changing fall speed; higher is
## snappier, lower is softer/slower (exponential response).
const AIR_POSE_RESPONSE := 10.0
## Maximum forward hand offset (px) in canonical rig-local +X. The LEFT root
## mirror makes this screen-left automatically; the offset ramps from zero at
## FORWARD_START_SPEED to full size at FORWARD_FULL_SPEED before/alongside lift.
const AIR_HAND_FORWARD := 220.0
const AIR_HAND_FORWARD_START_SPEED := 80.0
const AIR_HAND_FORWARD_FULL_SPEED := 520.0
## Maximum upward hand offset (px), applied as the main air-pose weight rises.
const AIR_HAND_LIFT := 680.0
## Alternating hand/foot flail amplitudes (px): Vector2(x, y) is the maximum
## horizontal/vertical oscillation around the captured takeoff pose.
const AIR_HAND_FLAIL_AMPLITUDE := Vector2(20.0, 16.0)
const AIR_FOOT_FLAIL_AMPLITUDE := Vector2(14.0, 10.0)
## Flail oscillation frequency in cycles per second (Hz); higher is faster.
const AIR_FLAIL_HZ := 12.0
## Hard-landing height thresholds (px of vertical fall distance). Falls below
## MIN are soft; MIN..MAX linearly scale the reaction; falls at/above MAX use
## the maximum compression and hand drop.
const HARD_LANDING_MIN_FALL_HEIGHT := 80.0
const HARD_LANDING_MAX_FALL_HEIGHT := 900.0
## Downward torso/pelvis compression range (px), selected from the fall-height
## scale above: MIN is the light hard landing, MAX is the deepest crouch.
const LANDING_COMPRESSION_MIN := 32.0
const LANDING_COMPRESSION_MAX := 160.0
## Downward hand reaction range (px), selected from the same fall-height scale;
## this makes a hard impact drive both arms sharply toward the ground.
const LANDING_HAND_DROP_MIN := 270.0
const LANDING_HAND_DROP_MAX := 520.0
## Landing impact phase durations in seconds: first compress downward, then
## recover back to the captured torso/hand pose (total is their sum).
const LANDING_COMPRESS_DURATION := 0.08
const LANDING_RECOVER_DURATION := 0.22
const FOOT_BLEND_RESPONSE := 14.0 const FOOT_BLEND_RESPONSE := 14.0
const PELVIS_MAX_OFFSET := 240.0 const PELVIS_MAX_OFFSET := 240.0
const PELVIS_RESPONSE := 10.0 const PELVIS_RESPONSE := 10.0
@@ -525,8 +572,21 @@ var _foot_contacts: Dictionary = {
"Right_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false},
} }
var _terrain_pelvis_offset: float = 0.0 var _terrain_pelvis_offset: float = 0.0
var _terrain_vertical_velocity: float = 0.0 var _terrain_air_velocity: Vector2 = Vector2.ZERO
var _terrain_airborne: bool = true var _terrain_motion: TerrainMotion = TerrainMotion.DISABLED
var _last_grounded_walk_velocity_x: float = 0.0
var _air_time: float = 0.0
var _air_pose_weight: float = 0.0
var _air_pose_base: Dictionary = {}
var _air_entry_root_y: float = 0.0
var _air_fall_distance: float = 0.0
var _landing_elapsed: float = 0.0
var _landing_impact_speed: float = 0.0
var _landing_compression: float = 0.0
var _landing_hand_drop: float = 0.0
var _landing_torso_base: Vector2 = Vector2.ZERO
var _landing_hand_start: Dictionary = {}
var _landing_contacts: Dictionary = {}
var _terrain_paused_walk_animation: bool = false var _terrain_paused_walk_animation: bool = false
var _terrain_following_enabled: bool = false var _terrain_following_enabled: bool = false
@@ -620,6 +680,11 @@ func _physics_process(delta: float) -> void:
_track_momentum(delta) _track_momentum(delta)
_update_rest_detection(delta) _update_rest_detection(delta)
_clear_terrain_pelvis_offset() _clear_terrain_pelvis_offset()
# A ledge departure inherits only this frame's grounded displacement. Reset
# before walking so nav waits, idle frames, and early returns cannot leak a
# stale velocity into a later fall.
if _terrain_following_enabled and _terrain_motion == TerrainMotion.GROUNDED:
_last_grounded_walk_velocity_x = 0.0
_update_walking(delta) _update_walking(delta)
_update_terrain_following(delta) _update_terrain_following(delta)
_settle_walk_markers() _settle_walk_markers()
@@ -668,9 +733,8 @@ func begin_terrain_fall() -> void:
_terrain_following_enabled = true _terrain_following_enabled = true
_clear_terrain_pelvis_offset() _clear_terrain_pelvis_offset()
_terrain_pelvis_offset = 0.0 _terrain_pelvis_offset = 0.0
_terrain_vertical_velocity = 0.0
_terrain_airborne = true
_terrain_paused_walk_animation = false _terrain_paused_walk_animation = false
_enter_airborne(0.0)
for leg_name: String in _foot_contacts: for leg_name: String in _foot_contacts:
_foot_contacts[leg_name] = {"valid": false, "locked": false} _foot_contacts[leg_name] = {"valid": false, "locked": false}
@@ -681,13 +745,17 @@ func snap_to_terrain() -> void:
func set_terrain_following_enabled(enabled: bool) -> void: func set_terrain_following_enabled(enabled: bool) -> void:
_terrain_following_enabled = enabled
if enabled: if enabled:
if _terrain_motion == TerrainMotion.DISABLED:
begin_terrain_fall()
return return
_terrain_following_enabled = false
_cancel_landing_impact(true)
_restore_air_pose()
_clear_terrain_pelvis_offset() _clear_terrain_pelvis_offset()
_terrain_pelvis_offset = 0.0 _terrain_pelvis_offset = 0.0
_terrain_vertical_velocity = 0.0 _terrain_air_velocity = Vector2.ZERO
_terrain_airborne = false _terrain_motion = TerrainMotion.DISABLED
_terrain_paused_walk_animation = false _terrain_paused_walk_animation = false
for leg_name: String in _foot_contacts: for leg_name: String in _foot_contacts:
_foot_contacts[leg_name] = {"valid": false, "locked": false} _foot_contacts[leg_name] = {"valid": false, "locked": false}
@@ -810,27 +878,18 @@ func _update_terrain_following(delta: float) -> void:
"weight": stance, "weight": stance,
"target": target, "target": target,
} }
if _terrain_motion == TerrainMotion.LANDING_IMPACT:
_update_landing_impact(delta)
return
if _terrain_motion == TerrainMotion.AIRBORNE:
_update_airborne(delta, center_probe, solved)
return
if not center_supported: if not center_supported:
_terrain_airborne = true _enter_airborne(_last_grounded_walk_velocity_x)
if _walking and _anim_player != null and _anim_player.is_playing(): _update_airborne(delta, center_probe, solved)
_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 return
var desired_offset := clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET) var desired_offset := clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET)
if _terrain_airborne: if _terrain_motion == TerrainMotion.AIRBORNE:
# Landing correction is immediate so a fast fall cannot tunnel a leg # Landing correction is immediate so a fast fall cannot tunnel a leg
# through a thin ramp between physics frames. # through a thin ramp between physics frames.
_terrain_pelvis_offset = desired_offset _terrain_pelvis_offset = desired_offset
@@ -840,8 +899,8 @@ func _update_terrain_following(delta: float) -> void:
# The correction accumulates, allowing consecutive ramps to climb far # The correction accumulates, allowing consecutive ramps to climb far
# beyond the single-frame compensation range. # beyond the single-frame compensation range.
_terrain_pelvis_offset = lerpf(0.0, desired_offset, pelvis_blend) _terrain_pelvis_offset = lerpf(0.0, desired_offset, pelvis_blend)
_terrain_airborne = false _terrain_motion = TerrainMotion.GROUNDED
_terrain_vertical_velocity = 0.0 _terrain_air_velocity = Vector2.ZERO
if _terrain_paused_walk_animation and _walking and _anim_player != null: if _terrain_paused_walk_animation and _walking and _anim_player != null:
_anim_player.play() _anim_player.play()
_terrain_paused_walk_animation = false _terrain_paused_walk_animation = false
@@ -867,6 +926,226 @@ func _update_terrain_following(delta: float) -> void:
wanted.y = surface.y wanted.y = surface.y
(item["target"] as Marker2D).global_position = wanted (item["target"] as Marker2D).global_position = wanted
func _enter_airborne(horizontal_velocity: float) -> void:
if _terrain_motion != TerrainMotion.AIRBORNE:
_air_pose_base.clear()
for marker_name: String in ["Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]:
var marker := _get_ik_marker(marker_name)
if marker != null:
_air_pose_base[marker_name] = marker.position
else:
push_warning("StickmanRig: missing IK marker '%s' for air pose." % marker_name)
_air_time = 0.0
_air_pose_weight = 0.0
_air_entry_root_y = global_position.y
_air_fall_distance = 0.0
_terrain_motion = TerrainMotion.AIRBORNE
_terrain_air_velocity = Vector2(horizontal_velocity, 0.0)
if _walking and _anim_player != null and _anim_player.is_playing():
_anim_player.pause()
_terrain_paused_walk_animation = true
for leg_name: String in _foot_contacts:
_foot_contacts[leg_name] = {"valid": false, "locked": false}
func _update_airborne(delta: float, center_probe: Dictionary, solved: Dictionary) -> void:
_terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta)
_terrain_air_velocity.y = minf(_terrain_air_velocity.y + TERRAIN_GRAVITY * delta, TERRAIN_MAX_FALL_SPEED)
var horizontal_motion := _sweep_airborne_horizontal_motion(Vector2(_terrain_air_velocity.x * delta, 0.0))
global_position += horizontal_motion
global_position.y += _terrain_air_velocity.y * delta
_air_fall_distance = maxf(_air_fall_distance, global_position.y - _air_entry_root_y)
_air_time += delta
_apply_air_pose(delta)
var center_world := to_global(NAV_AGENT_LOCAL_POS)
# Probe after both swept X and gravity Y motion. The pre-motion sample may
# belong to a ledge we have already passed, especially at low frame rates.
center_probe = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
var valid := bool(center_probe.get("valid", false))
var landing_delta := INF
if valid:
landing_delta = (center_probe["world_pos"] as Vector2).y - center_world.y
var swept_support := valid and _terrain_air_velocity.y >= 0.0 \
and landing_delta >= -TERRAIN_SUPPORT_RANGE \
and landing_delta <= TERRAIN_SUPPORT_RANGE + _terrain_air_velocity.y * delta
if swept_support:
global_position.y += landing_delta
var impact_speed := maxf(_terrain_air_velocity.y, 0.0)
_terrain_air_velocity = Vector2.ZERO
if _air_fall_distance >= HARD_LANDING_MIN_FALL_HEIGHT:
_begin_landing(impact_speed, center_probe, solved)
else:
_restore_air_pose()
_terrain_motion = TerrainMotion.GROUNDED
if _terrain_paused_walk_animation and _walking and _anim_player != null:
_anim_player.play()
_terrain_paused_walk_animation = false
func _apply_air_pose(delta: float) -> void:
if _air_pose_base.is_empty():
return
var speed := maxf(_terrain_air_velocity.y, 0.0)
var lift_weight := smoothstep(AIR_POSE_START_SPEED, AIR_POSE_FULL_SPEED, speed)
var forward_weight := smoothstep(AIR_HAND_FORWARD_START_SPEED, AIR_HAND_FORWARD_FULL_SPEED, speed)
_air_pose_weight = lerpf(_air_pose_weight, lift_weight, 1.0 - exp(-AIR_POSE_RESPONSE * delta))
var phase := TAU * AIR_FLAIL_HZ * _air_time
for marker_name: String in _air_pose_base:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var base: Vector2 = _air_pose_base[marker_name]
var right_side := marker_name.begins_with("Right")
var local_phase := phase + (PI if right_side else 0.0)
var offset := Vector2.ZERO
if marker_name.ends_with("Hand"):
offset = Vector2(AIR_HAND_FORWARD * forward_weight, -AIR_HAND_LIFT * _air_pose_weight) \
+ Vector2(sin(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.y) * _air_pose_weight
else:
offset = Vector2(sin(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.y)
marker.position = base + offset if marker_name.ends_with("Hand") else base + offset * _air_pose_weight
func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2:
if is_zero_approx(motion.x):
return motion
var space_state := get_world_2d().direct_space_state
if space_state == null:
push_warning("StickmanRig: no physics space for airborne sweep.")
return motion
var safe_fraction := 1.0
var excluded: Array[RID] = []
for proxy: AnimatableBody2D in _collision_bodies.values():
if proxy != null and is_instance_valid(proxy):
excluded.append(proxy.get_rid())
for proxy: AnimatableBody2D in _collision_bodies.values():
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
continue
var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape
query.transform = shape_node.global_transform
query.motion = motion
query.collision_mask = COLLISION_LAYER
query.exclude = excluded
var cast := space_state.cast_motion(query)
if cast.size() >= 1:
safe_fraction = minf(safe_fraction, cast[0])
if safe_fraction < 0.9999:
_terrain_air_velocity.x = 0.0
return motion * safe_fraction
func _begin_landing(impact_speed: float, center_probe: Dictionary, solved: Dictionary) -> void:
_terrain_motion = TerrainMotion.LANDING_IMPACT
_landing_elapsed = 0.0
_landing_impact_speed = impact_speed
var impact_weight := clampf(inverse_lerp(
HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT, _air_fall_distance), 0.0, 1.0)
_landing_compression = lerpf(LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX, impact_weight)
_landing_hand_drop = lerpf(LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX, impact_weight)
var torso := _get_ik_marker("Torso")
if torso != null:
_landing_torso_base = torso.position
_landing_hand_start.clear()
for hand_name: String in ["Left_Hand", "Right_Hand"]:
var hand := _get_ik_marker(hand_name)
if hand != null:
_landing_hand_start[hand_name] = hand.position
_landing_contacts.clear()
var center_y := (center_probe.get("world_pos", global_position) as Vector2).y
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var target := _get_ik_marker(leg_name)
if target == null:
continue
# Take an independent post-correction sample for each foot. `solved`
# belongs to the pre-air-motion controller pass and must not lock feet to
# geometry left behind during the fall.
var base := _leg_base_markers.get(leg_name) as Marker2D
var sample_world := base.global_position if base != null else target.global_position
var foot_probe := _probe_terrain_fan(sample_world, TERRAIN_FOOT_PROBE_OFFSETS)
var contact := Vector2(target.global_position.x, center_y)
if bool(foot_probe.get("valid", false)):
contact = foot_probe["world_pos"] as Vector2
_landing_contacts[leg_name] = contact
target.global_position = contact
func _update_landing_impact(delta: float) -> void:
_landing_elapsed += delta
var total := LANDING_COMPRESS_DURATION + LANDING_RECOVER_DURATION
var torso_offset := 0.0
if _landing_elapsed <= LANDING_COMPRESS_DURATION:
var t := clampf(_landing_elapsed / LANDING_COMPRESS_DURATION, 0.0, 1.0)
torso_offset = _landing_compression * (1.0 - (1.0 - t) * (1.0 - t))
else:
var t := clampf((_landing_elapsed - LANDING_COMPRESS_DURATION) / LANDING_RECOVER_DURATION, 0.0, 1.0)
torso_offset = _landing_compression * cos(t * PI * 0.5)
var torso := _get_ik_marker("Torso")
if torso != null:
torso.position = _landing_torso_base + Vector2.DOWN * torso_offset
for leg_name: String in _landing_contacts:
var target := _get_ik_marker(leg_name)
if target != null:
target.global_position = _landing_contacts[leg_name]
var hand_t := clampf(_landing_elapsed / total, 0.0, 1.0)
var hand_drop_weight := torso_offset / maxf(_landing_compression, 0.0001)
for hand_name: String in _landing_hand_start:
var hand := _get_ik_marker(hand_name)
var base: Vector2 = _air_pose_base.get(hand_name, _landing_hand_start[hand_name])
if hand != null:
hand.position = ( _landing_hand_start[hand_name] as Vector2).lerp(base, hand_t) \
+ Vector2.DOWN * _landing_hand_drop * hand_drop_weight
if _landing_elapsed >= total:
_finish_landing_impact()
func _finish_landing_impact() -> void:
var torso := _get_ik_marker("Torso")
if torso != null:
torso.position = _landing_torso_base
_restore_air_pose()
_landing_contacts.clear()
_landing_hand_start.clear()
_landing_elapsed = 0.0
_landing_compression = 0.0
_landing_hand_drop = 0.0
_terrain_motion = TerrainMotion.GROUNDED
if _terrain_paused_walk_animation and _walking and _anim_player != null:
_anim_player.play()
_terrain_paused_walk_animation = false
func _cancel_landing_impact(restore_pose: bool) -> void:
if _terrain_motion == TerrainMotion.LANDING_IMPACT and restore_pose:
var torso := _get_ik_marker("Torso")
if torso != null:
torso.position = _landing_torso_base
_landing_contacts.clear()
_landing_hand_start.clear()
_landing_elapsed = 0.0
_landing_compression = 0.0
_landing_hand_drop = 0.0
func _restore_air_pose() -> void:
for marker_name: String in _air_pose_base:
var marker := _get_ik_marker(marker_name)
if marker != null:
marker.position = _air_pose_base[marker_name]
_air_pose_base.clear()
_air_pose_weight = 0.0
_air_time = 0.0
_air_entry_root_y = 0.0
_air_fall_distance = 0.0
func _is_action_queue_blocked() -> bool:
return _terrain_motion == TerrainMotion.LANDING_IMPACT
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Per-frame rest detection # Per-frame rest detection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -961,6 +1240,15 @@ func is_in_ragdoll() -> bool:
func set_ragdoll(enabled: bool) -> void: func set_ragdoll(enabled: bool) -> void:
if enabled: if enabled:
if _terrain_motion == TerrainMotion.AIRBORNE or _terrain_motion == TerrainMotion.LANDING_IMPACT:
_cancel_landing_impact(false)
_air_pose_base.clear()
_air_pose_weight = 0.0
_air_time = 0.0
_air_entry_root_y = 0.0
_air_fall_distance = 0.0
_terrain_motion = TerrainMotion.DISABLED
_terrain_air_velocity = Vector2.ZERO
match state: match state:
RigState.RAGDOLL: RigState.RAGDOLL:
return return
@@ -989,6 +1277,10 @@ func request_recovery() -> void:
## tween. Used by the sandbox stage so a stickman "reappears" at its starting ## tween. Used by the sandbox stage so a stickman "reappears" at its starting
## position/state on return to EDIT (instead of animating the recovery glide). ## position/state on return to EDIT (instead of animating the recovery glide).
func snap_to_standing() -> void: func snap_to_standing() -> void:
_cancel_landing_impact(true)
_restore_air_pose()
_terrain_air_velocity = Vector2.ZERO
_terrain_motion = TerrainMotion.DISABLED
var previous_state := state var previous_state := state
if state == RigState.RAGDOLL: if state == RigState.RAGDOLL:
_destroy_ragdoll() _destroy_ragdoll()
@@ -996,7 +1288,9 @@ func snap_to_standing() -> void:
_cancel_recovery() _cancel_recovery()
_clear_impact_reactions(true) _clear_impact_reactions(true)
_hide_speech() _hide_speech()
_restore_standing_markers() # EDIT/DIRECT reset is the authored neutral stance, not an adapter-derived
# spawn silhouette. This also releases any terrain-owned marker snapshot.
_restore_standing_markers(false)
# Re-show the kinematic puppet and re-enable IK. # Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null: if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true _skeleton.modification_stack.enabled = true
@@ -1028,8 +1322,8 @@ func snap_to_standing() -> void:
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change). ## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
func _restore_standing_markers() -> void: func _restore_standing_markers(use_spawn_pose: bool = true) -> void:
var pose: Dictionary = _spawn_pose if not _spawn_pose.is_empty() else STAND_POSE var pose: Dictionary = _spawn_pose if use_spawn_pose and not _spawn_pose.is_empty() else STAND_POSE
for marker_name: String in pose: for marker_name: String in pose:
var marker := _get_ik_marker(marker_name) var marker := _get_ik_marker(marker_name)
if marker == null: if marker == null:
@@ -2377,6 +2671,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
if state != RigState.ANIMATED: if state != RigState.ANIMATED:
push_warning("StickmanRig: walk_to ignored; not ANIMATED.") push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
return return
if _is_action_queue_blocked():
push_warning("StickmanRig: walk_to ignored during landing impact.")
return
_walk_target_feet = target _walk_target_feet = target
_walk_speed_current = speed if speed > 0.0 else walk_speed _walk_speed_current = speed if speed > 0.0 else walk_speed
_nav_agent.max_speed = _walk_speed_current _nav_agent.max_speed = _walk_speed_current
@@ -2422,7 +2719,7 @@ 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: if _terrain_following_enabled and _terrain_motion != TerrainMotion.GROUNDED:
return 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
@@ -2482,7 +2779,9 @@ func _update_walking(delta: float) -> void:
# DIRECT branch: an off-mesh waypoint is a supported case — steer # DIRECT branch: an off-mesh waypoint is a supported case — steer
# straight at the clicked point, ignoring the nav mesh. # straight at the clicked point, ignoring the nav mesh.
root_target = Vector2(final_root.x, global_position.y) root_target = Vector2(final_root.x, global_position.y)
var before_x := global_position.x
global_position = global_position.move_toward(root_target, _walk_speed_current * delta) global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
_last_grounded_walk_velocity_x = (global_position.x - before_x) / delta if delta > 0.0 else 0.0
# 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:
@@ -2586,7 +2885,7 @@ func _show_visible_speech() -> void:
func _update_speech(delta: float) -> void: func _update_speech(delta: float) -> void:
var visible_owner_changed := false var visible_owner_changed := false
if not _primary_speech.is_empty(): if not _primary_speech.is_empty() and not _is_action_queue_blocked():
_primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta _primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
_speech_time_left = float(_primary_speech["time_left"]) _speech_time_left = float(_primary_speech["time_left"])
if _speech_time_left <= 0.0: if _speech_time_left <= 0.0:
@@ -2770,6 +3069,8 @@ func is_ragdoll_at_rest() -> bool:
func _update_runner(delta: float) -> void: func _update_runner(delta: float) -> void:
if _runner_state != RunnerState.EXECUTING: if _runner_state != RunnerState.EXECUTING:
return return
if _is_action_queue_blocked():
return
match _action_phase: match _action_phase:
ActionPhase.NONE: ActionPhase.NONE:
_advance_to_next_action() _advance_to_next_action()
@@ -2846,7 +3147,7 @@ func _finish_action() -> void:
func _cancel_current_primary() -> void: func _cancel_current_primary() -> void:
_cancel_walking() _cancel_walking(not _is_action_queue_blocked())
_primary_speech.clear() _primary_speech.clear()
_speech_active = false _speech_active = false
_speech_time_left = 0.0 _speech_time_left = 0.0
+375
View File
@@ -0,0 +1,375 @@
# Phase 6a — Airborne Momentum, Procedural Air Pose, and Landing Absorption
Status: implemented and user-verified (2026-09-17).
Source proposal: `plans/PHASE_6a_AIRBORNE_LANDING.md`.
## 1. Scope and verified implementation decisions
Phase 6a extends the existing PLAY-only terrain controller in `StickmanRig`. It does not add a
new public action, change saved queue payloads, add a new `RigState`, or alter the navigation
mesh. The feature has three active internal terrain-motion phases plus the disabled value:
```gdscript
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
```
`TerrainMotion` replaces `_terrain_airborne` as the terrain-phase authority;
`_terrain_following_enabled` remains the PLAY lifecycle enable flag. `RigState` remains the
authoritative high-level puppet state
(`ANIMATED`, `RAGDOLL`, `RECOVERING`); terrain motion runs only while `state == ANIMATED`.
The approved implementation resolves the design choices as follows:
- Extreme animated falls do **not** automatically become ragdolls in this phase. Auto-ragdoll
would silently change director intent and, because PLAY currently sets `auto_recover = false`,
could strand a rig. Explicit queued/reactive/direct ragdoll requests remain authoritative.
- Airborne lateral contact is a swept stop: move only through the collision-safe fraction and
set horizontal air velocity to zero. There is no bounce, wall slide, or ledge grab.
- The root remains upright on slopes. At impact, both lower-leg targets lock independently to
their own upward terrain samples, so a ramp or stair may produce unequal foot heights while
the torso compresses vertically.
- Queue work cannot start or advance during `LANDING_IMPACT`. Incoming sequential/takeover work
is retained and runs afterward; speech overlays remain non-blocking and continue on their
existing overlay clock. A direct `set_ragdoll(true)` is the one physical preemption and cancels
landing absorption before the normal ragdoll handoff.
- The air pose is additive to a live pose captured at takeoff. It therefore preserves arbitrary
walk/idle/custom marker poses instead of replacing hands and feet with a factory pose.
## 2. Targets and ownership
| Target | Change |
| --- | --- |
| `scripts/stickman_rig.gd` (`StickmanRig`) | Owns the terrain-motion enum, two-axis air velocity, swept horizontal movement, air-pose capture/solve, height-scaled landing impact timeline, independent landing contacts, and queue barrier. |
| `tests/test_phase6a_airborne_landing.gd` | Focused headless regression suite for state transitions, signed takeoff momentum, forward-before-lift air pose, exact restoration, height-scaled landing, impact hand drop, queue barrier, and ragdoll cleanup. It passes 24/24 assertions. |
| `master_rig.tscn` | No change. Existing `IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg,Torso}` are sufficient. |
| `scripts/sandbox_stage.gd` | No behavioral change expected. Existing PLAY order (`begin_terrain_fall()` then `start_queue()`) and EDIT/DIRECT cleanup (`stop_queue()`, disable terrain following, `snap_to_standing()`) remain the lifecycle contract. |
| Layout/theme | No UI, scene-layout, or theme update. |
## 3. Static contracts
The shipped tuning constants sit beside the existing terrain constants. They are production
defaults, not saved settings:
```gdscript
const AIR_DRAG_X := 0.75 # exponential damping coefficient, s^-1
const AIR_POSE_START_SPEED := 240.0 # downward px/s
const AIR_POSE_FULL_SPEED := 1200.0 # downward px/s
const AIR_POSE_RESPONSE := 10.0 # exponential blend response, s^-1
const AIR_HAND_FORWARD := 220.0 # rig-local +X px at full reach
const AIR_HAND_FORWARD_START_SPEED := 80.0 # downward px/s
const AIR_HAND_FORWARD_FULL_SPEED := 520.0 # downward px/s
const AIR_HAND_LIFT := 680.0 # rig-local px upward at full weight
const AIR_HAND_FLAIL_AMPLITUDE := Vector2(20.0, 16.0)
const AIR_FOOT_FLAIL_AMPLITUDE := Vector2(14.0, 10.0)
const AIR_FLAIL_HZ := 12.0
const HARD_LANDING_MIN_FALL_HEIGHT := 80.0 # vertical fall px
const HARD_LANDING_MAX_FALL_HEIGHT := 900.0 # vertical fall px
const LANDING_COMPRESSION_MIN := 32.0 # torso-marker px downward
const LANDING_COMPRESSION_MAX := 160.0
const LANDING_HAND_DROP_MIN := 270.0 # hand-marker px downward
const LANDING_HAND_DROP_MAX := 520.0
const LANDING_COMPRESS_DURATION := 0.08
const LANDING_RECOVER_DURATION := 0.22
```
`AIR_DRAG_X` exponentially damps signed horizontal takeoff velocity; the shipped `0.75` keeps
the arc visibly strong. The main air-pose weight ramps from zero to one over downward speeds
`240..1200` px/s and follows speed changes with response `10`. Hands first reach in canonical
rig-local `+X` over `80..520` px/s, then lift up to `680` px; the root's LEFT mirror makes the
same local pose face screen-left. Hand/foot flail amplitudes are `(x, y)` rig-local pixels and
run at 12 Hz with opposite phase on the two sides.
Hard-landing reaction is driven by vertical fall distance, not impact speed: below 80 px the
contact is soft; from 80..900 px compression and hand drop interpolate linearly; at 900 px or
more they clamp to 160 px torso compression and 520 px downward hand drop. The impact timeline
compresses for 0.08 s and recovers for 0.22 s (0.30 s total).
Existing `TERRAIN_GRAVITY`, `TERRAIN_MAX_FALL_SPEED`, upward-normal filtering, probe fans,
`TERRAIN_SUPPORT_RANGE`, `PELVIS_MAX_OFFSET`, and feet/root coordinate conventions remain the
single sources of truth.
Replace `_terrain_vertical_velocity` with `_terrain_air_velocity: Vector2`. Add:
```gdscript
var _terrain_motion: TerrainMotion = TerrainMotion.DISABLED
var _last_grounded_walk_velocity_x: float = 0.0
var _air_time: float = 0.0
var _air_pose_weight: float = 0.0
var _air_pose_base: Dictionary = {} # marker name -> captured local Vector2
var _landing_elapsed: float = 0.0
var _landing_impact_speed: float = 0.0
var _landing_compression: float = 0.0
var _landing_torso_base: Vector2 = Vector2.ZERO
var _landing_hand_start: Dictionary = {} # hand marker name -> local Vector2
var _landing_contacts: Dictionary = {} # leg marker name -> world Vector2
```
Required private signatures:
```gdscript
func _enter_airborne(horizontal_velocity: float) -> void
func _update_airborne(delta: float, center_probe: Dictionary, solved: Dictionary) -> void
func _apply_air_pose(delta: float) -> void
func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2
func _begin_landing(impact_speed: float, center_probe: Dictionary, solved: Dictionary) -> void
func _update_landing_impact(delta: float) -> void
func _finish_landing_impact() -> void
func _cancel_landing_impact(restore_pose: bool) -> void
func _restore_air_pose() -> void
func _is_action_queue_blocked() -> bool
```
Public method signatures and existing signals remain unchanged. No landing signal is added;
landing is an internal locomotion detail, and `action_started`, `action_finished`, `arrived`, and
`queue_finished` must retain their current meanings and cardinality.
## 4. State and frame flow
`_physics_process()` keeps its current broad order, with explicit terrain ownership:
```text
track root momentum
ragdoll rest update
clear one-frame pelvis correction
walking (grounded only; records actual signed X velocity)
terrain controller
AIRBORNE -> swept X, gravity Y, air pose, landing detection
GROUNDED -> existing foot/stance/pelvis solve
LANDING_IMPACT-> locked feet, torso/hand recovery timeline
settle markers
speech clocks (primary clock frozen only by the landing barrier; overlay clock continues)
queue runner (returns immediately while barrier is active)
head pin / flinch / collision-proxy sync
```
Transitions:
```text
DISABLED --begin_terrain_fall()------------------------------> AIRBORNE
AIRBORNE --valid descending center support-------------------> GROUNDED (soft contact)
AIRBORNE --fall distance >= HARD_LANDING_MIN_FALL_HEIGHT-----> LANDING_IMPACT
GROUNDED --center support lost-------------------------------> AIRBORNE
LANDING_IMPACT --0.08 s compress + 0.22 s recover-----------> GROUNDED
any terrain phase --set_terrain_following_enabled(false)----> DISABLED
AIRBORNE/LANDING_IMPACT --set_ragdoll(true)-----------------> cancel terrain pose, then RAGDOLL
```
`begin_terrain_fall()` always starts in `AIRBORNE` with zero air velocity. A rig placed above
ground therefore drops vertically; a rig that walks off a ledge inherits movement from the last
grounded walking frame.
For compatibility, `snap_to_terrain()` remains an alias of `begin_terrain_fall()`, and
`set_terrain_following_enabled(true)` calls `begin_terrain_fall()` only when currently
`DISABLED` (it is otherwise idempotent). Passing `false` always performs the full transition to
`DISABLED` and cleanup described below.
## 5. Airborne momentum and collision
### 5.1 Takeoff velocity
`_update_walking()` records its actual signed displacement each grounded physics frame:
```gdscript
_last_grounded_walk_velocity_x = (global_position.x - before_x) / delta
```
Record zero when walking performs no horizontal displacement. On the first supported-to-
unsupported transition, `_enter_airborne()` receives that value and captures the live limb
marker pose once. It must not recapture on later airborne frames.
Each airborne frame uses stable exponential damping and the existing gravity cap:
```gdscript
_terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta)
_terrain_air_velocity.y = minf(
_terrain_air_velocity.y + TERRAIN_GRAVITY * delta,
TERRAIN_MAX_FALL_SPEED)
```
Apply the collision-safe horizontal displacement before vertical displacement. The sweep must
reuse the already-built animated collision proxy shapes/transforms and exclude every proxy RID
belonging to this rig. Test the desired `Vector2(vx * delta, 0)` against collision mask 1, take
the minimum safe fraction across the valid proxy shapes, move by that fraction, and set `vx = 0`
when the fraction is less than 1. Missing world/proxy data degrades to the requested movement
with one `StickmanRig:` warning rather than crashing. This deliberately covers TerrainBlock,
props, and other animated rig proxies without introducing a second body-size approximation.
There is no airborne navigation update: `_update_walking()` remains paused in `AIRBORNE` and
`LANDING_IMPACT`. The active `walk_to` action stays active, and navigation resumes from the
actual landing X after recovery. Overshooting a waypoint in the air is allowed; the existing
nav/direct steering corrects it after landing.
### 5.2 Swept landing detection
Keep the existing upward-facing TerrainBlock filter. While descending, a valid center surface is
a landing candidate when its signed Y delta is no farther than the normal support range plus the
current frame's downward displacement. This swept allowance prevents a low-frame-rate fall from
crossing a thin ramp between samples. Upward motion or a surface above the accepted support band
does not count as a landing.
On contact, capture `impact_speed = maxf(_terrain_air_velocity.y, 0.0)` **before** clearing air
velocity. Apply the full center support correction immediately, set air velocity to zero, and
stop lateral momentum; Phase 6a does not preserve a slope-tangent ground velocity.
## 6. Procedural air pose and exact restoration
At takeoff capture local positions for `Left_Hand`, `Right_Hand`, `Left_Leg`, and `Right_Leg`.
The walk animation is already paused by the terrain controller, so these values are stable and
also preserve a non-locomotion/custom pose.
Compute:
```gdscript
target_weight = smoothstep(
AIR_POSE_START_SPEED,
AIR_POSE_FULL_SPEED,
maxf(_terrain_air_velocity.y, 0.0))
_air_pose_weight = lerpf(
_air_pose_weight,
target_weight,
1.0 - exp(-AIR_POSE_RESPONSE * delta))
phase = TAU * AIR_FLAIL_HZ * _air_time
```
Hands receive an upward/away-from-center drag offset plus elliptical sine/cosine flutter. The
right hand uses `phase + PI`, so the hands are anti-phased. Feet receive only the smaller flutter
offsets, also anti-phased. All offsets are multiplied by `_air_pose_weight` and are assigned as
`captured_base + offset` each frame (never accumulated). Because offsets are rig-local, the root
X mirror naturally mirrors the effect for LEFT without side-specific world-space code.
On a soft landing, restore all four captured marker positions exactly and clear the snapshot. On
an impact landing, feet switch immediately to terrain contacts; hands blend from their touchdown
positions back to their captured values across the complete 0.30 s impact timeline. Completion
writes exact captured values before clearing state. No residual procedural offset may survive
landing, terrain disable, `snap_to_standing()`, or ragdoll entry.
## 7. Landing absorption and slopes
For `_air_fall_distance < HARD_LANDING_MIN_FALL_HEIGHT`, transition directly to `GROUNDED`,
restore the air pose, and resume a paused walk animation. No queue barrier or compression is
created.
Otherwise map the clamped vertical fall distance linearly from
`[HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT]` to
`[LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX]` and independently to
`[LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX]`, then enter `LANDING_IMPACT`. The measured
downward impact speed is captured for the landing event, but does not select the reaction depth.
At entry:
1. Keep the walk animation paused.
2. Capture the current `IK_Targets/Torso.position` as `_landing_torso_base`.
3. Capture each valid per-foot terrain sample independently in world space. If one foot fan is
invalid, use that foot's X with the accepted center surface Y as its deterministic fallback.
4. Mark both foot contacts locked and write the live leg targets to those world contacts.
During the first 0.08 s, move only the torso marker downward from base to the computed depth with
a quadratic ease-out. During the following 0.22 s, move it back to base with sine-out. Reassert
both foot target global positions every frame; the root stays upright and stationary. Hands begin
at their touchdown positions, move sharply downward by the height-scaled hand-drop amount during
compression, and blend back to their captured takeoff positions over the full 0.30 s timeline.
Thus a slope/stair keeps two unequal ankle heights and lets TwoBoneIK absorb the torso motion as
knee flexion. Do not rotate the root to the surface normal and do not average the two foot
heights.
At completion, write the exact torso base and hand bases, clear landing contacts/timers, change
to `GROUNDED`, then resume the paused walk animation. The normal Phase 6 stance/swing solver owns
the next frame.
This is implemented as a physics-time elapsed-state solver, not a `Tween`. That keeps queue pause,
terrain disable, ragdoll interruption, and headless tests deterministic and avoids stale tween
callbacks writing markers after a mode change.
## 8. Queue and interruption semantics
`_is_action_queue_blocked()` is true only in `LANDING_IMPACT`.
- `_update_runner(delta)` returns before its phase match while blocked. `WAITING` time does not
decrement; `NONE` does not start the next item; `WALKING` cannot finish; ragdoll/recover items
cannot start.
- The primary speech clock is frozen while blocked. Overlay speech remains independent and
continues to count down/display as today.
- `enqueue_reactive()` may still update `_run_queue`. Sequential/takeover items are inserted with
the existing authored-future preservation rules but cannot start until landing completes.
If cancellation touches a walk pose during impact, `_cancel_current_primary()` must skip marker
restoration; the landing solver owns those markers until release.
- `stop_queue()` cancels queue work but does not cancel physical landing while PLAY continues.
- Direct `walk_to()` during `LANDING_IMPACT` is rejected with the normal `StickmanRig:` warning;
queued walking waits behind the barrier.
- Direct `set_ragdoll(true)` cancels landing state with `restore_pose = false`, preserves the
current solved impact pose, and then performs the existing instant ragdoll handoff. Queued or
reactive ragdoll actions wait behind the landing barrier like other primary actions.
- `set_terrain_following_enabled(false)` and `snap_to_standing()` cancel the impact, restore/clear
procedural markers, clear velocity and contacts, and leave no deferred completion work.
Existing action signals do not fire for the physical landing itself. A held action emits exactly
one normal `action_finished` when it later completes, and `queue_finished` still emits once only
after the runtime lane is exhausted.
## 9. Null safety and interaction boundaries
- Missing required IK markers disable only the corresponding procedural channel and issue a
prefixed warning; terrain gravity and queue execution must not crash.
- Collision-proxy sweeps are read-only physics queries. They do not replace proxy synchronization
or Phase 4.5 collision reactions.
- RAGDOLL and RECOVERING never run the air/landing solvers. Existing ragdoll momentum capture sees
the root's actual air movement through `_track_momentum()`.
- The feature is PLAY-only through the existing stage lifecycle. It never changes authored
transforms, `.scene` serialization, action registry payloads, or editor UI.
## 10. Implementation validation
The implemented feature was syntax-checked and exercised with the focused Phase 6a suite:
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_rig.gd" --path "C:\Godot4\stickman"
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase6a_airborne_landing.gd" --verbose
```
Results on 2026-09-17:
- `scripts/stickman_rig.gd` headless syntax check: passed.
- `tests/test_phase6a_airborne_landing.gd`: **24/24 assertions passed**.
- Existing `tests/test_phase4b_terrain.gd`: **8/8 assertions passed**.
- User editor verification: forward-before-lift arm motion, stronger curved momentum, height-scaled
hard landing, impact arm drop, and clean handoff/reset behavior confirmed.
The focused suite covers:
1. `DISABLED -> AIRBORNE`, signed takeoff velocity, full-speed pose offsets, exact air-pose
restoration, and grounded idle velocity reset.
2. Forward hand reach before lift, subsequent hand lift, exponential air drag, and direct ragdoll
cleanup from airborne motion.
3. Minimum and maximum fall-height mapping to compression, queue blocking during impact, deepest
compression at 0.08 s, sharp downward hand reaction, stale-contact rejection, exact torso/hand
restoration at 0.30 s, and barrier release.
Broader terrain, collision-proxy, queue, and slope regressions remain covered by the existing
Phase 4b/4.5 suites and the manual editor scenarios below; they are not duplicated in the focused
24-assertion script.
Manual Godot-editor verification after automated tests:
- walk off both sides of a raised platform and observe a curved, decaying lateral trajectory;
- land at low, medium, and maximum fall speeds on flat ground, both ramp directions, and a stair
edge; confirm visible air flail, knee compression, independent feet, and no marker snap;
- queue `walk_to -> wait -> speak`, fall during the walk, and confirm strict order after recovery;
- inject reactive sequential and overlay actions during compression; and
- switch PLAY to DIRECT/EDIT and trigger ragdoll during compression, confirming clean immediate
ownership handoffs and no later pose write.
## 11. Architectural alternatives and deferred work
- A `CharacterBody2D` root would provide `move_and_slide()` and richer wall/slope response, but it
conflicts with the current Node2D rig root, separately synchronized ten-part proxies, ragdoll
handoff, and authored transform restoration. Reuse proxy-shape sweeps for this bounded phase.
- Adding `AnimationTargets/*_Base` nodes for both hands would generalize layered animation
ownership, but takeoff capture already preserves the live custom pose and avoids a scene/clip
migration. Revisit only if procedural arm layers must run while the base animation keeps playing.
- Automatic ragdoll escalation, bounce, sliding, root slope tilt, air control, and ledge grabs are
intentionally deferred; each changes director-visible behavior beyond landing polish.
- Ten proxy casts per airborne rig per physics frame are acceptable at current sandbox scale but
are recorded as a scaling trade-off in `docs/tech_debt_and_optimizations.md`.
+127
View File
@@ -0,0 +1,127 @@
extends SceneTree
## Focused deterministic regression checks for the Phase 6a terrain controller.
## Runtime terrain/ramp integration remains covered by the existing stage suites.
const RIG_SCENE := preload("res://master_rig.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var rig := RIG_SCENE.instantiate() as Node2D
root.add_child(rig)
await physics_frame
var controller := rig as StickmanRig
_check(controller != null, "master rig exposes StickmanRig")
if controller != null:
_test_air_pose_and_momentum(controller)
_test_grounded_reset_and_ragdoll_cleanup(controller)
_test_landing_timeline_and_barrier(controller)
rig.queue_free()
await process_frame
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_air_pose_and_momentum(rig: StickmanRig) -> void:
var left_hand := rig._get_ik_marker("Left_Hand")
var right_hand := rig._get_ik_marker("Right_Hand")
var left_base := left_hand.position
var right_base := right_hand.position
rig._enter_airborne(300.0)
_check(rig._terrain_motion == RIG.TerrainMotion.AIRBORNE, "enter_airborne selects AIRBORNE")
_check(is_equal_approx(rig._terrain_air_velocity.x, 300.0), "takeoff keeps signed horizontal velocity")
rig._terrain_air_velocity.y = RIG.AIR_POSE_FULL_SPEED
rig._air_time = 0.0
rig._apply_air_pose(1.0)
_check(rig._air_pose_weight > 0.99 and rig._air_pose_weight <= 1.0, "air pose weight clamps at full fall speed")
_check(left_hand.position != left_base and right_hand.position != right_base, "air pose offsets both hands")
rig._restore_air_pose()
_check(left_hand.position.is_equal_approx(left_base) and right_hand.position.is_equal_approx(right_base), "air pose restores exact captured markers")
rig._terrain_motion = RIG.TerrainMotion.GROUNDED
rig._enter_airborne(0.0)
var forward_left_base: Vector2 = rig._air_pose_base["Left_Hand"]
var forward_right_base: Vector2 = rig._air_pose_base["Right_Hand"]
rig._terrain_air_velocity.y = RIG.AIR_HAND_FORWARD_START_SPEED + 20.0
rig._air_time = 0.0
rig._apply_air_pose(1.0)
_check(left_hand.position.x > forward_left_base.x and right_hand.position.x > forward_right_base.x, "hands reach forward before lift engages")
_check(is_equal_approx(left_hand.position.y, forward_left_base.y) and is_equal_approx(right_hand.position.y, forward_right_base.y), "early forward reach does not lift hands")
rig._terrain_air_velocity.y = RIG.AIR_POSE_FULL_SPEED
rig._apply_air_pose(1.0)
_check(left_hand.position.y < forward_left_base.y and right_hand.position.y < forward_right_base.y, "hands lift after forward reach")
rig._restore_air_pose()
rig._terrain_air_velocity = Vector2(300.0, 0.0)
rig._terrain_air_velocity.x *= exp(-RIG.AIR_DRAG_X * 0.1)
_check(rig._terrain_air_velocity.x > 0.0 and rig._terrain_air_velocity.x < 300.0, "air drag preserves forward momentum while decaying it")
func _test_grounded_reset_and_ragdoll_cleanup(rig: StickmanRig) -> void:
rig._terrain_following_enabled = true
rig._terrain_motion = RIG.TerrainMotion.GROUNDED
rig._last_grounded_walk_velocity_x = 999.0
rig._physics_process(0.0)
_check(is_zero_approx(rig._last_grounded_walk_velocity_x), "grounded idle frame clears stale walk velocity")
rig._enter_airborne(-240.0)
rig._terrain_air_velocity.y = 640.0
rig._apply_air_pose(0.1)
rig.set_ragdoll(true)
_check(rig.state == RIG.RigState.RAGDOLL, "direct ragdoll enters from ordinary airborne motion")
_check(rig._terrain_motion == RIG.TerrainMotion.DISABLED and rig._air_pose_base.is_empty(), "direct ragdoll clears airborne terrain ownership")
_check(rig._terrain_air_velocity.is_zero_approx() and is_zero_approx(rig._air_pose_weight), "direct ragdoll clears air velocity and pose weight")
rig.snap_to_standing()
func _test_landing_timeline_and_barrier(rig: StickmanRig) -> void:
var torso := rig._get_ik_marker("Torso")
var left_leg := rig._get_ik_marker("Left_Leg")
var right_leg := rig._get_ik_marker("Right_Leg")
rig._enter_airborne(0.0)
var base_torso := torso.position
var left_hand := rig._get_ik_marker("Left_Hand")
var right_hand := rig._get_ik_marker("Right_Hand")
var left_hand_base := left_hand.position
var right_hand_base := right_hand.position
var contacts := {
"Left_Leg": {"surface": left_leg.global_position + Vector2(0.0, 1200.0), "surface_valid": true},
"Right_Leg": {"surface": right_leg.global_position + Vector2(0.0, 1300.0), "surface_valid": true},
}
rig._air_fall_distance = RIG.HARD_LANDING_MIN_FALL_HEIGHT
rig._begin_landing(0.0, {"world_pos": torso.global_position}, contacts)
_check(is_equal_approx(rig._landing_compression, RIG.LANDING_COMPRESSION_MIN), "minimum hard fall height maps to minimum compression")
_check(rig._is_action_queue_blocked(), "landing blocks primary queue")
rig._update_landing_impact(RIG.LANDING_COMPRESS_DURATION)
_check(is_equal_approx(torso.position.y, base_torso.y + RIG.LANDING_COMPRESSION_MIN), "impact reaches deepest compression at 0.08s")
_check(left_hand.position.y > left_hand_base.y and right_hand.position.y > right_hand_base.y, "hard landing sharply drives hands downward")
_check(left_leg.global_position.y < torso.global_position.y + 500.0 and right_leg.global_position.y < torso.global_position.y + 500.0, "landing ignores stale pre-motion solved contacts")
rig._update_landing_impact(RIG.LANDING_RECOVER_DURATION)
_check(rig._terrain_motion == RIG.TerrainMotion.GROUNDED, "landing returns to GROUNDED after 0.30s")
_check(torso.position.is_equal_approx(base_torso), "landing restores torso exactly")
_check(left_hand.position.is_equal_approx(left_hand_base) and right_hand.position.is_equal_approx(right_hand_base), "landing restores hand bases exactly")
_check(not rig._is_action_queue_blocked(), "queue barrier clears after landing")
rig._enter_airborne(0.0)
rig._air_fall_distance = RIG.HARD_LANDING_MAX_FALL_HEIGHT
rig._begin_landing(0.0, {"world_pos": torso.global_position}, contacts)
_check(is_equal_approx(rig._landing_compression, RIG.LANDING_COMPRESSION_MAX), "maximum hard fall height maps to maximum compression")
rig._cancel_landing_impact(true)
rig._restore_air_pose()
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
@@ -0,0 +1 @@
uid://c2w7tlb4cke6t