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:
+334
-33
@@ -33,6 +33,10 @@ enum RunnerState { IDLE, EXECUTING }
|
||||
## Which kind of queued action is currently executing (Phase 3a).
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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_GRAVITY := 1400.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 PELVIS_MAX_OFFSET := 240.0
|
||||
const PELVIS_RESPONSE := 10.0
|
||||
@@ -525,8 +572,21 @@ var _foot_contacts: Dictionary = {
|
||||
"Right_Leg": {"valid": false, "locked": false},
|
||||
}
|
||||
var _terrain_pelvis_offset: float = 0.0
|
||||
var _terrain_vertical_velocity: float = 0.0
|
||||
var _terrain_airborne: bool = true
|
||||
var _terrain_air_velocity: Vector2 = Vector2.ZERO
|
||||
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_following_enabled: bool = false
|
||||
|
||||
@@ -620,6 +680,11 @@ func _physics_process(delta: float) -> void:
|
||||
_track_momentum(delta)
|
||||
_update_rest_detection(delta)
|
||||
_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_terrain_following(delta)
|
||||
_settle_walk_markers()
|
||||
@@ -668,9 +733,8 @@ func begin_terrain_fall() -> void:
|
||||
_terrain_following_enabled = true
|
||||
_clear_terrain_pelvis_offset()
|
||||
_terrain_pelvis_offset = 0.0
|
||||
_terrain_vertical_velocity = 0.0
|
||||
_terrain_airborne = true
|
||||
_terrain_paused_walk_animation = false
|
||||
_enter_airborne(0.0)
|
||||
for leg_name: String in _foot_contacts:
|
||||
_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:
|
||||
_terrain_following_enabled = enabled
|
||||
if enabled:
|
||||
if _terrain_motion == TerrainMotion.DISABLED:
|
||||
begin_terrain_fall()
|
||||
return
|
||||
_terrain_following_enabled = false
|
||||
_cancel_landing_impact(true)
|
||||
_restore_air_pose()
|
||||
_clear_terrain_pelvis_offset()
|
||||
_terrain_pelvis_offset = 0.0
|
||||
_terrain_vertical_velocity = 0.0
|
||||
_terrain_airborne = false
|
||||
_terrain_air_velocity = Vector2.ZERO
|
||||
_terrain_motion = TerrainMotion.DISABLED
|
||||
_terrain_paused_walk_animation = false
|
||||
for leg_name: String in _foot_contacts:
|
||||
_foot_contacts[leg_name] = {"valid": false, "locked": false}
|
||||
@@ -810,27 +878,18 @@ func _update_terrain_following(delta: float) -> void:
|
||||
"weight": stance,
|
||||
"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:
|
||||
_terrain_airborne = true
|
||||
if _walking and _anim_player != null and _anim_player.is_playing():
|
||||
_anim_player.pause()
|
||||
_terrain_paused_walk_animation = true
|
||||
_terrain_vertical_velocity = minf(
|
||||
_terrain_vertical_velocity + TERRAIN_GRAVITY * delta,
|
||||
TERRAIN_MAX_FALL_SPEED)
|
||||
global_position.y += _terrain_vertical_velocity * delta
|
||||
_terrain_pelvis_offset = 0.0
|
||||
for leg_name: String in solved:
|
||||
var air_item: Dictionary = solved[leg_name]
|
||||
# Keep the animated leg pose rigidly attached to the falling root.
|
||||
(air_item["target"] as Marker2D).position = \
|
||||
(air_item["base_marker"] as Marker2D).position
|
||||
if DEBUG_TERRAIN and Engine.get_physics_frames() % 30 == 0:
|
||||
print("[terrain] AIR root=", global_position, " center_dy=", center_delta,
|
||||
" walk=", _walking, " ", debug_samples)
|
||||
_enter_airborne(_last_grounded_walk_velocity_x)
|
||||
_update_airborne(delta, center_probe, solved)
|
||||
return
|
||||
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
|
||||
# through a thin ramp between physics frames.
|
||||
_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
|
||||
# beyond the single-frame compensation range.
|
||||
_terrain_pelvis_offset = lerpf(0.0, desired_offset, pelvis_blend)
|
||||
_terrain_airborne = false
|
||||
_terrain_vertical_velocity = 0.0
|
||||
_terrain_motion = TerrainMotion.GROUNDED
|
||||
_terrain_air_velocity = Vector2.ZERO
|
||||
if _terrain_paused_walk_animation and _walking and _anim_player != null:
|
||||
_anim_player.play()
|
||||
_terrain_paused_walk_animation = false
|
||||
@@ -867,6 +926,226 @@ func _update_terrain_following(delta: float) -> void:
|
||||
wanted.y = surface.y
|
||||
(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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -961,6 +1240,15 @@ func is_in_ragdoll() -> bool:
|
||||
|
||||
func set_ragdoll(enabled: bool) -> void:
|
||||
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:
|
||||
RigState.RAGDOLL:
|
||||
return
|
||||
@@ -989,6 +1277,10 @@ func request_recovery() -> void:
|
||||
## 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).
|
||||
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
|
||||
if state == RigState.RAGDOLL:
|
||||
_destroy_ragdoll()
|
||||
@@ -996,7 +1288,9 @@ func snap_to_standing() -> void:
|
||||
_cancel_recovery()
|
||||
_clear_impact_reactions(true)
|
||||
_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.
|
||||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||||
_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).
|
||||
func _restore_standing_markers() -> void:
|
||||
var pose: Dictionary = _spawn_pose if not _spawn_pose.is_empty() else STAND_POSE
|
||||
func _restore_standing_markers(use_spawn_pose: bool = true) -> void:
|
||||
var pose: Dictionary = _spawn_pose if use_spawn_pose and not _spawn_pose.is_empty() else STAND_POSE
|
||||
for marker_name: String in pose:
|
||||
var marker := _get_ik_marker(marker_name)
|
||||
if marker == null:
|
||||
@@ -2377,6 +2671,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
|
||||
if state != RigState.ANIMATED:
|
||||
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
|
||||
return
|
||||
if _is_action_queue_blocked():
|
||||
push_warning("StickmanRig: walk_to ignored during landing impact.")
|
||||
return
|
||||
_walk_target_feet = target
|
||||
_walk_speed_current = speed if speed > 0.0 else walk_speed
|
||||
_nav_agent.max_speed = _walk_speed_current
|
||||
@@ -2422,7 +2719,7 @@ func _update_walking(delta: float) -> void:
|
||||
if state != RigState.ANIMATED:
|
||||
_cancel_walking(false)
|
||||
return
|
||||
if _terrain_following_enabled and _terrain_airborne:
|
||||
if _terrain_following_enabled and _terrain_motion != TerrainMotion.GROUNDED:
|
||||
return
|
||||
# Defer all nav reads until the map has actually synchronized. An unsynced
|
||||
# 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
|
||||
# straight at the clicked point, ignoring the nav mesh.
|
||||
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)
|
||||
_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
|
||||
# the residual offset away so the rig lands exactly on the waypoint.
|
||||
if dist_to_final <= ARRIVE_DISTANCE:
|
||||
@@ -2586,7 +2885,7 @@ func _show_visible_speech() -> void:
|
||||
|
||||
func _update_speech(delta: float) -> void:
|
||||
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
|
||||
_speech_time_left = float(_primary_speech["time_left"])
|
||||
if _speech_time_left <= 0.0:
|
||||
@@ -2770,6 +3069,8 @@ func is_ragdoll_at_rest() -> bool:
|
||||
func _update_runner(delta: float) -> void:
|
||||
if _runner_state != RunnerState.EXECUTING:
|
||||
return
|
||||
if _is_action_queue_blocked():
|
||||
return
|
||||
match _action_phase:
|
||||
ActionPhase.NONE:
|
||||
_advance_to_next_action()
|
||||
@@ -2846,7 +3147,7 @@ func _finish_action() -> void:
|
||||
|
||||
|
||||
func _cancel_current_primary() -> void:
|
||||
_cancel_walking()
|
||||
_cancel_walking(not _is_action_queue_blocked())
|
||||
_primary_speech.clear()
|
||||
_speech_active = false
|
||||
_speech_time_left = 0.0
|
||||
|
||||
Reference in New Issue
Block a user