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
+334 -33
View File
@@ -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