- 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.
376 lines
20 KiB
Markdown
376 lines
20 KiB
Markdown
# 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`.
|