Files
stickman/docs/specs/phase_6a_airborne_landing.md
T

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: `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:
```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/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:
```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/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.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/architecture/tech_debt_and_optimizations.md`.