Add ramp scene and implement Phase 6 for dynamic IK and terrain-following locomotion

- Created a new scene file `ramp.scene` with terrain and stickman objects, including event rules for ragdoll and recovery actions.
- Documented Phase 6 implementation in `PHASE_6_GROUND_SNAPPING.md`, detailing the continuous dynamic IK, terrain-following locomotion, and constraints for stickman rigs.
- Defined scene structure, layered IK ownership, probe contracts, and per-frame solve order for terrain interaction.
- Verified functionality for terrain snapping, stance/swing blending, pelvis/root compensation, and navigation synchronization.
This commit is contained in:
2026-09-16 09:33:07 -04:00
parent a7c58705e1
commit 08247eadef
7 changed files with 1088 additions and 44 deletions
+69 -12
View File
@@ -327,6 +327,7 @@ var _rule_more_popup: PopupMenu = null
## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>"
## and "<rig_id>:<prop_id>".
var _area_overlap: Dictionary = {}
var _area_previous_bounds: Dictionary = {}
var _collision_pairs: Dictionary = {}
## Lightweight toast text + countdown (cleared in _process on expiry).
@@ -634,6 +635,7 @@ func _enter_edit_shared() -> void:
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing()
rig.clear_reactive_actions()
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
@@ -653,6 +655,7 @@ func _enter_edit_shared() -> void:
_director_visuals.set_enabled(true)
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
@@ -713,6 +716,7 @@ func _enter_play_mode() -> void:
_set_direct_controls_visible(false)
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
_area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear()
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
@@ -722,6 +726,7 @@ func _enter_play_mode() -> void:
if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG
rig.auto_recover = false
rig.begin_terrain_fall()
rig.start_queue()
rig_count += 1
_stage_dbg("PLAY rigs=%d" % rig_count)
@@ -2016,14 +2021,15 @@ func _build_navigation() -> void:
_rebake_navigation()
## Rebuild the navigation mesh from every TerrainBlock child of World: each
## block's world-space polygon is convex-decomposed and fan-triangulated into
## one manual NavigationPolygon.
## Rebuild navigation from exposed top edges only. Each qualifying terrain edge
## becomes a shallow ribbon just above its collision surface, so agents route
## over floors/ramps rather than through solid terrain polygons.
func _rebake_navigation() -> void:
if _nav_region == null or not is_instance_valid(_nav_region):
return
var verts := PackedVector2Array()
var triangles: Array[PackedInt32Array] = []
var surfaces: Array[Dictionary] = []
for child: Node in _world.get_children():
var block := child as TerrainBlock
if block == null:
@@ -2031,14 +2037,28 @@ func _rebake_navigation() -> void:
var pts := PackedVector2Array()
for p: Vector2 in block.polygon_points:
pts.append(block.transform * p)
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
if pieces.is_empty():
pieces = [pts]
for piece: PackedVector2Array in pieces:
var base: int = verts.size()
verts.append_array(piece)
for i: int in range(1, piece.size() - 1):
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
for i: int in pts.size():
var a := pts[i]
var b := pts[(i + 1) % pts.size()]
var edge := b - a
if edge.length_squared() <= 0.001:
continue
# TerrainUtils stores clockwise polygons; this right normal is
# outward, so only its upward-facing edges are walkable.
var outward := Vector2(edge.y, -edge.x).normalized()
if outward.y > -0.15:
continue
surfaces.append({"a": a, "b": b, "normal": outward})
for surface: Dictionary in surfaces:
_append_navigation_ribbon(verts, triangles, surface["a"], surface["b"], surface["normal"])
# Join only short, climbable gaps between exposed surface ends. This connects
# authored stair treads without making cliffs or terrain interiors walkable.
for i: int in surfaces.size():
for j: int in range(i + 1, surfaces.size()):
for a: Vector2 in [surfaces[i]["a"], surfaces[i]["b"]]:
for b: Vector2 in [surfaces[j]["a"], surfaces[j]["b"]]:
if absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0:
_append_navigation_ribbon(verts, triangles, a, b, Vector2.UP)
var poly := NavigationPolygon.new()
poly.set_vertices(verts)
for tri: PackedInt32Array in triangles:
@@ -2047,6 +2067,24 @@ func _rebake_navigation() -> void:
if DEBUG_STAGE:
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
func _append_navigation_ribbon(
verts: PackedVector2Array,
triangles: Array[PackedInt32Array],
a: Vector2,
b: Vector2,
normal: Vector2
) -> void:
if a.distance_squared_to(b) <= 0.001:
return
var base: int = verts.size()
verts.append_array(PackedVector2Array([
a + normal * 4.0, b + normal * 4.0,
b - normal * 12.0, a - normal * 12.0,
]))
triangles.append(PackedInt32Array([base, base + 1, base + 2]))
triangles.append(PackedInt32Array([base, base + 2, base + 3]))
# ---------------------------------------------------------------------------
# Signal handlers
# ---------------------------------------------------------------------------
@@ -3110,12 +3148,31 @@ func _update_area_entry() -> void:
movables.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze:
movables.append(node)
# Build one swept bound per movable before iterating areas. The stage parent
# polls before child rigs apply this frame's free-fall displacement, so the
# previous->current union prevents fast airborne motion from tunneling
# through a trigger between two observed poses.
var swept_bounds: Dictionary = {}
var live_ids: Dictionary = {}
for node: Node2D in movables:
var node_id := node.get_instance_id()
var current_bounds := STAGE_SELECTION.get_world_aabb(node).abs()
var swept := current_bounds
if _area_previous_bounds.has(node_id):
swept = swept.merge((_area_previous_bounds[node_id] as Rect2).abs())
swept_bounds[node_id] = swept
_area_previous_bounds[node_id] = current_bounds
live_ids[node_id] = true
for old_id: int in _area_previous_bounds.keys().duplicate():
if not live_ids.has(old_id):
_area_previous_bounds.erase(old_id)
for area: TriggerArea in areas:
var area_rect := (area.global_transform * area.get_area_rect()).abs()
for node: Node2D in movables:
var point := _movable_point(node)
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var inside: bool = _movable_overlaps_area(node, area_rect)
var inside: bool = (swept_bounds.get(
node.get_instance_id(), Rect2()) as Rect2).intersects(area_rect, true)
var was: bool = bool(_area_overlap.get(key, false))
if inside and not was:
_area_overlap[key] = true
+278 -17
View File
@@ -240,11 +240,26 @@ const NAV_TARGET_DESIRED_DISTANCE := 12.0
## How many post-sync physics frames to wait for the nav agent's reachability
## flag before deciding the walk is genuinely off-mesh (latch "direct").
const LATCH_PROBE_MAX_FRAMES := 6
const TERRAIN_PROBE_UP := 96.0
const TERRAIN_PROBE_DOWN := 700.0
const TERRAIN_NORMAL_MIN_Y := -0.15
const TERRAIN_SUPPORT_RANGE := 160.0
const TERRAIN_MAX_FOOT_SURFACE_DROP := 72.0
const TERRAIN_FOOT_GUARD_HEIGHT := 24.0
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
const FOOT_BLEND_RESPONSE := 14.0
const PELVIS_MAX_OFFSET := 240.0
const PELVIS_RESPONSE := 10.0
const WALK_CYCLE_DURATION := 0.8
## Rig-local anchor for the speech bubble, above the head.
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
const DEBUG_TERRAIN := false
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
const DEBUG_RECOVERY := false
@@ -504,6 +519,16 @@ var _walk_mode_latched: bool = false
var _walk_latch_probe_frames: int = 0
var _walk_settle_frames: int = 0
var _walk_done: bool = false
var _leg_base_markers: Dictionary = {}
var _foot_contacts: Dictionary = {
"Left_Leg": {"valid": false, "locked": false},
"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_paused_walk_animation: bool = false
var _terrain_following_enabled: bool = false
var _ragdoll_at_rest: bool = false
@@ -540,6 +565,12 @@ func _ready() -> void:
_anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer
if _anim_player == null:
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base := get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D
if base == null:
push_warning("StickmanRig: missing AnimationTargets/%s_Base." % leg_name)
else:
_leg_base_markers[leg_name] = base
_resolve_bend_modifications()
_resolve_limb_bones()
@@ -588,7 +619,9 @@ func _ready() -> void:
func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
_clear_terrain_pelvis_offset()
_update_walking(delta)
_update_terrain_following(delta)
_settle_walk_markers()
_update_speech(delta)
_update_runner(delta)
@@ -622,6 +655,218 @@ func _track_momentum(delta: float) -> void:
_prev_global_pos = global_position
_prev_global_rot = global_rotation
func _clear_terrain_pelvis_offset() -> void:
# Terrain height is accumulated into the runtime root while PLAY is active.
# This value is only the last frame's correction, not an offset to undo.
_terrain_pelvis_offset = 0.0
## Arms the animated terrain controller without teleporting. An unsupported rig
## free-falls until its centerline reaches a TerrainBlock support surface.
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
for leg_name: String in _foot_contacts:
_foot_contacts[leg_name] = {"valid": false, "locked": false}
## Kept as a compatibility alias for callers introduced during Phase 6.
func snap_to_terrain() -> void:
begin_terrain_fall()
func set_terrain_following_enabled(enabled: bool) -> void:
_terrain_following_enabled = enabled
if enabled:
return
_clear_terrain_pelvis_offset()
_terrain_pelvis_offset = 0.0
_terrain_vertical_velocity = 0.0
_terrain_airborne = false
_terrain_paused_walk_animation = false
for leg_name: String in _foot_contacts:
_foot_contacts[leg_name] = {"valid": false, "locked": false}
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base := _leg_base_markers.get(leg_name) as Marker2D
var target := _get_ik_marker(leg_name)
if base != null and target != null:
target.position = base.position
func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary:
var space_state := get_world_2d().direct_space_state
if space_state == null:
return {"valid": false}
var query := PhysicsRayQueryParameters2D.create(
world_point + Vector2.UP * TERRAIN_PROBE_UP,
world_point + Vector2.DOWN * probe_down)
query.collision_mask = 1
query.collide_with_areas = false
var excluded: Array[RID] = []
for proxy: AnimatableBody2D in _collision_bodies.values():
if proxy != null and is_instance_valid(proxy):
var rid := proxy.get_rid()
if rid.is_valid():
excluded.append(rid)
query.exclude = excluded
var hit := space_state.intersect_ray(query)
if hit.is_empty() or not (hit.get("collider") is TerrainBlock):
return {"valid": false}
var normal: Vector2 = hit.get("normal", Vector2.ZERO)
if normal.y > TERRAIN_NORMAL_MIN_Y:
return {"valid": false}
return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal}
## Samples a small horizontal fan and returns the highest valid terrain surface.
## This keeps an overlapping ramp authoritative over flat ground underneath and
## prevents a single seam/edge miss from dropping the controller for one frame.
func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary:
var best: Dictionary = {"valid": false}
for x_offset: float in offsets:
var hit := _probe_terrain_below(world_point + Vector2(x_offset, 0.0))
if not bool(hit.get("valid", false)):
continue
if not bool(best.get("valid", false)) \
or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y:
best = hit
return best
func _stance_weight(leg_name: String) -> float:
if not _walking or _anim_player == null:
return 1.0
var phase := fmod(_anim_player.get_current_animation_position(), WALK_CYCLE_DURATION)
# Continuous complementary weights remove the one-frame 0->1 target snap at
# each gait handoff while preserving the clip's alternating half-cycle.
var right_weight := 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
func _update_terrain_following(delta: float) -> void:
if not _terrain_following_enabled \
or state != RigState.ANIMATED or _leg_base_markers.size() != 2:
_terrain_pelvis_offset = 0.0
return
# Terrain following requires the live leg solvers; preserve the old pinned
# LEFT idle behavior only when no terrain controller is available.
_set_limb_mods_enabled(true)
var center_world := to_global(NAV_AGENT_LOCAL_POS)
var center_probe := _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
var center_delta := INF
if bool(center_probe.get("valid", false)):
center_delta = (center_probe["world_pos"] as Vector2).y - center_world.y
var center_supported := bool(center_probe.get("valid", false)) \
and absf(center_delta) <= TERRAIN_SUPPORT_RANGE
var center_surface_y := (center_probe["world_pos"] as Vector2).y \
if bool(center_probe.get("valid", false)) else INF
var solved: Dictionary = {}
var debug_samples: Array[String] = []
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base := _leg_base_markers[leg_name] as Marker2D
var target := _get_ik_marker(leg_name)
if base == null or target == null:
continue
var base_world := base.global_position
var probe := _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS)
var probe_delta := INF
if bool(probe.get("valid", false)):
probe_delta = (probe["world_pos"] as Vector2).y - base_world.y
# A foot may reach above/below the pelvis support surface, but must not
# choose the flat ground far underneath an overlapping ramp.
var within_reach := absf(probe_delta) <= PELVIS_MAX_OFFSET
if within_reach and center_supported:
within_reach = (probe["world_pos"] as Vector2).y \
<= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP
var stance := _stance_weight(leg_name)
debug_samples.append("%s hit=%s dy=%.1f stance=%.1f" % [
leg_name, str(bool(probe.get("valid", false))), probe_delta, stance])
var contact: Dictionary = _foot_contacts[leg_name]
if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach:
if not bool(contact.get("locked", false)):
contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]}
# Refresh a planted contact when a moved/edited terrain surface changes.
elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0:
contact["world_pos"] = probe["world_pos"]
elif stance < 0.5 or not within_reach:
contact = {"valid": bool(probe.get("valid", false)), "locked": false}
_foot_contacts[leg_name] = contact
var floor_world := base_world
if bool(contact.get("locked", false)):
floor_world = contact["world_pos"]
elif bool(probe.get("valid", false)) and within_reach:
floor_world = probe["world_pos"]
solved[leg_name] = {
"base": base_world,
"base_marker": base,
"floor": floor_world,
"surface": probe.get("world_pos", base_world) if within_reach else base_world,
"surface_valid": bool(probe.get("valid", false)) and within_reach,
"weight": stance,
"target": target,
}
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)
return
var desired_offset := clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET)
if _terrain_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
else:
var pelvis_blend := 1.0 - exp(-PELVIS_RESPONSE * delta)
# Apply a fraction of the remaining error directly to the runtime root.
# 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
if _terrain_paused_walk_animation and _walking and _anim_player != null:
_anim_player.play()
_terrain_paused_walk_animation = false
global_position.y += _terrain_pelvis_offset
if DEBUG_TERRAIN and Engine.get_physics_frames() % 30 == 0:
print("[terrain] GROUND root=", global_position, " offset=", _terrain_pelvis_offset,
" center_dy=", center_delta, " walk=", _walking, " ", debug_samples)
for leg_name: String in solved:
var item: Dictionary = solved[leg_name]
# Re-read the animated base after moving the root. Using the pre-pelvis
# world point here counter-moved the swing foot and visibly bent the knee.
var base_after := (item["base_marker"] as Marker2D).global_position
var contact_weight := float(item["weight"])
var surface: Vector2 = item["surface"]
if bool(item["surface_valid"]):
var clearance := surface.y - base_after.y
var guard_weight := 1.0 - clampf(
clearance / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0)
contact_weight = maxf(contact_weight, smoothstep(0.0, 1.0, guard_weight))
var wanted := base_after
wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, contact_weight)
if bool(item["surface_valid"]) and wanted.y > surface.y:
wanted.y = surface.y
(item["target"] as Marker2D).global_position = wanted
# ---------------------------------------------------------------------------
# Per-frame rest detection
# ---------------------------------------------------------------------------
@@ -835,6 +1080,11 @@ func initialize_spawn_pose_from_skeleton() -> void:
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
marker.rotation = target.get("rot", marker.rotation)
if marker_name == "Left_Leg" or marker_name == "Right_Leg":
var base_marker := get_node_or_null(
NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D
if base_marker != null:
base_marker.position = marker.position
_spawn_pose = pose.duplicate(true)
_spawn_limb_rotations.clear()
for joint: String in LIMB_BONE_PATHS:
@@ -2172,6 +2422,8 @@ func _update_walking(delta: float) -> void:
if state != RigState.ANIMATED:
_cancel_walking(false)
return
if _terrain_following_enabled and _terrain_airborne:
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
# otherwise end the walk after a single move_toward step (~5 px).
@@ -2207,7 +2459,9 @@ func _update_walking(delta: float) -> void:
# repath, so checking it directly would leave the path empty forever.
var next_feet := _nav_agent.get_next_path_position()
var final_root := _walk_target_feet + FOOT_OFFSET
var dist_to_final := global_position.distance_to(final_root)
# Terrain following owns vertical motion. Navigation supplies horizontal
# progress only, so slope elevation cannot fight the ankle/pelvis solve.
var dist_to_final := absf(global_position.x - final_root.x)
var root_target: Vector2
if _walk_mode == "nav":
# Terminate once the nav agent reports its path complete AND the rig is
@@ -2215,24 +2469,24 @@ func _update_walking(delta: float) -> void:
# feet-space) while the rig's hard arrival radius is 8 px, so chasing the
# last stale next-waypoint would oscillate the rig around the 16-px band.
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
global_position = final_root
global_position.x = final_root.x
_finish_walk("arrive")
return
# Near the destination, ignore the (possibly behind-path) next waypoint
# and steer straight at the final target so the rig cannot reverse.
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
root_target = final_root
root_target = Vector2(final_root.x, global_position.y)
else:
root_target = next_feet + FOOT_OFFSET
root_target = Vector2(next_feet.x + FOOT_OFFSET.x, global_position.y)
else:
# DIRECT branch: an off-mesh waypoint is a supported case — steer
# straight at the clicked point, ignoring the nav mesh.
root_target = final_root
root_target = Vector2(final_root.x, global_position.y)
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
# 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:
global_position = final_root
global_position.x = final_root.x
_finish_walk("arrive")
return
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
@@ -2412,28 +2666,35 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
_run_cursor = 0
var primary: Array[Dictionary] = []
var takeover_seen := false
for action: Dictionary in actions:
var role: int = ACTION_REGISTRY.reactive_role(action)
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
_speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
continue
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
_cancel_current_primary()
_overlay_speech.clear()
_hide_speech()
var kept: Array[Dictionary] = []
for item: Dictionary in _run_queue.slice(_run_cursor):
if not bool(item.get("reactive", false)):
kept.append(item)
_run_queue = kept
_run_cursor = 0
primary.clear()
# A rule batch such as [ragdoll, recover] is an ordered sequence.
# Interrupt the old work once at the first takeover; later takeover
# actions in the same batch must not erase earlier batch actions.
if not takeover_seen:
takeover_seen = true
_cancel_current_primary()
_overlay_speech.clear()
_hide_speech()
var kept: Array[Dictionary] = []
for item: Dictionary in _run_queue.slice(_run_cursor):
if not bool(item.get("reactive", false)):
kept.append(item)
_run_queue = kept
_run_cursor = 0
primary.clear()
primary.append(action.duplicate(true))
else:
primary.append(action.duplicate(true))
if primary.is_empty():
return
_cancel_current_primary()
if not takeover_seen:
_cancel_current_primary()
var injected: Array[Dictionary] = []
for action: Dictionary in primary:
injected.append({ "action": action, "queue_index": -1, "reactive": true })