20 KiB
Phase 6a — Airborne Momentum, Procedural Air Pose, and Landing Absorption
Status: implemented and user-verified (2026-09-17).
Source proposal: docs/archive/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:
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 directset_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/characters/stickman/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/editors/stage/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:
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:
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:
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:
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:
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:
_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:
_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:
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:
- Keep the walk animation paused.
- Capture the current
IK_Targets/Torso.positionas_landing_torso_base. - 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.
- 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.WAITINGtime does not decrement;NONEdoes not start the next item;WALKINGcannot 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()duringLANDING_IMPACTis rejected with the normalStickmanRig:warning; queued walking waits behind the barrier. - Direct
set_ragdoll(true)cancels landing state withrestore_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)andsnap_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,
.sceneserialization, action registry payloads, or editor UI.
10. Implementation validation
The implemented feature was syntax-checked and exercised with the focused Phase 6a suite:
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/characters/stickman/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/characters/stickman/stickman_rig.gdheadless 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:
DISABLED -> AIRBORNE, signed takeoff velocity, full-speed pose offsets, exact air-pose restoration, and grounded idle velocity reset.- Forward hand reach before lift, subsequent hand lift, exponential air drag, and direct ragdoll cleanup from airborne motion.
- 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
CharacterBody2Droot would providemove_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/*_Basenodes 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/architecture/tech_debt_and_optimizations.md.