Files
stickman/scripts/stickman_rig.gd
T
ryan 1f91f3d2e5 Add headless regression tests for Phase 4b features
- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
2026-09-04 15:08:08 -04:00

1420 lines
56 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name StickmanRig
extends Node2D
## StickmanRig - Runtime owner of facing direction and per-joint bone bend for
## master_rig.tscn (Phase 9 / Task 4).
##
## Attached to the `Master` root node of master_rig.tscn. Owns the facing
## preset, the four per-joint TwoBoneIK "Flip Bend Direction" flags, and the
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
## reordering run only at runtime (_ready + setters on a live instance).
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
## Facing profiles. Values are used directly as facing-menu item ids in the
## harness (0/1/2), so they must stay stable.
enum FacingProfile { LEFT, RIGHT, FORWARD }
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
enum BendDirection { NORMAL, INVERTED }
## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll);
## RECOVERING snaps the skeleton back to the captured rest pose and tweens the
## IK targets to the standing pose before returning to ANIMATED.
enum RigState { ANIMATED, RAGDOLL, RECOVERING }
## Director action-runner execution state (Phase 3a).
enum RunnerState { IDLE, EXECUTING }
## Which kind of queued action is currently executing (Phase 3a).
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const SKELETON_PATH := "Skeleton2D"
const BODY_CONTAINER_PATH := "Body"
## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend
## Direction" flag is user-controllable.
const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"]
## Lower-bone NodePath (relative to Skeleton2D) per bend joint. Used both to
## resolve the TwoBoneIK modification (via joint_two_bone2d_node) and to report
## each joint's world position for hit-testing.
const BEND_JOINT_BONE_PATHS: Dictionary = {
"LeftArm": "Torso/LeftUpperArm/LeftLowerArm",
"RightArm": "Torso/RightUpperArm/RightLowerArm",
"LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg",
"RightLeg": "Torso/RightUpperLeg/RightLowerLeg",
}
## flip_bend_direction value per facing profile, keyed by bend-joint name.
const PROFILE_FLAGS: Dictionary = {
FacingProfile.LEFT: { "LeftArm": false, "RightArm": false, "LeftLeg": true, "RightLeg": true },
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
}
## Body/* visual part node names in draw order (back-to-front) per profile.
## First entry backmost, last frontmost. Upper limbs behind lower limbs; on the
## far (behind-torso) side the arm pair draws behind the leg pair, on the near
## side the arm pair draws in front; head always frontmost.
const Z_ORDER_BY_PROFILE: Dictionary = {
FacingProfile.FORWARD: [
"Body",
"LeftUpperLeg", "RightUpperLeg",
"LeftLowerLeg", "RightLowerLeg",
"LeftUpperArm", "RightUpperArm",
"LeftLowerArm", "RightLowerArm",
"Head",
],
FacingProfile.LEFT: [
"LeftUpperArm", "LeftLowerArm",
"LeftUpperLeg", "LeftLowerLeg",
"Body",
"RightUpperLeg", "RightLowerLeg",
"RightUpperArm", "RightLowerArm",
"Head",
],
FacingProfile.RIGHT: [
"RightUpperArm", "RightLowerArm",
"RightUpperLeg", "RightLowerLeg",
"Body",
"LeftUpperLeg", "LeftLowerLeg",
"LeftUpperArm", "LeftLowerArm",
"Head",
],
}
# ---------------------------------------------------------------------------
# Ragdoll constants
# ---------------------------------------------------------------------------
const ANIMATION_PLAYER_PATH := "AnimationPlayer"
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const RAGDOLL_LIMB_RADIUS := 8.0
const RAGDOLL_TORSO_RADIUS := 12.0
const RAGDOLL_HEAD_RADIUS := 100.0
## Visual mesh colors: the rig's authored part color (gray Line2D limbs) and the
## head's filled white circle. Collision shapes are invisible in-game, so each
## ragdoll body gets a matching visible mesh (Line2D capsule / Polygon2D circle).
const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488)
const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE
const RAGDOLL_CIRCLE_SEGMENTS := 32
# ---------------------------------------------------------------------------
# Blend / recovery constants
# ---------------------------------------------------------------------------
## Rest detection thresholds (linear px/s, angular rad/s) for the ragdoll
## torso. 5.0 px/s (not the plan's 0.1) — a soft-pinned ragdoll micro-jitters
## around ~0.5 px/s even when fully settled, so 0.1 is never reached. A body
## that the physics engine has put to sleep also counts as at rest.
const REST_LINEAR_THRESHOLD := 5.0
const REST_ANGULAR_THRESHOLD := 0.1
## Duration of the stand-up tween (captured pose -> STAND_POSE).
const STAND_UP_DURATION := 0.8
## Extra hold after rest is detected before recovery captures the pose.
const STABILIZATION_DELAY := 0.1
## Pin softness applied to every ragdoll joint at build time.
const RAGDOLL_TARGET_SOFTNESS := 0.2
## IK-target standing positions (rig-local), matching master_rig.tscn defaults.
const STAND_POSE: Dictionary = {
"Torso": { "pos": Vector2(0, 10), "rot": 0.0 },
"Head": { "pos": Vector2(100, -614), "rot": 0.0 },
"Left_Hand": { "pos": Vector2(90, 110), "rot": 0.0 },
"Right_Hand": { "pos": Vector2(-90, 110), "rot": 0.0 },
"Left_Leg": { "pos": Vector2(-110, 380), "rot": 0.0 },
"Right_Leg": { "pos": Vector2(110, 390), "rot": 0.0 },
}
## IK-target marker node paths (rig-root relative), keyed by marker name.
const IK_TARGET_PATHS: Dictionary = {
"Torso": "IK_Targets/Torso",
"Head": "IK_Targets/Head",
"Left_Hand": "IK_Targets/Left_Hand",
"Right_Hand": "IK_Targets/Right_Hand",
"Left_Leg": "IK_Targets/Left_Leg",
"Right_Leg": "IK_Targets/Right_Leg",
}
# ---------------------------------------------------------------------------
# Director / navigation constants (Phase 3a)
# ---------------------------------------------------------------------------
## Feet -> root translation (a ground point -> the hip/root position).
const FOOT_OFFSET := Vector2(0.0, -385.0)
## The navigation agent sits at the feet, on the nav mesh.
const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0)
## Fallback arrival distance (px) to the root destination.
const ARRIVE_DISTANCE := 8.0
const NAV_PATH_DESIRED_DISTANCE := 8.0
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
## 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
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void:
if DEBUG_WALK:
print("[walk] ", msg)
## Preloaded (not a class_name type) so this script compiles even when the
## editor's global class cache is stale.
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
## Ragdoll body definitions, ordered parent-before-child. `node_path` is
## Skeleton2D-relative for bones and rig-root-relative for the head visual.
## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head).
const RAGDOLL_BODIES: Array[Dictionary] = [
{ "key": "torso", "kind": "bone", "node_path": "Torso", "parent": "", "shape": "capsule", "radius": RAGDOLL_TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 },
{ "key": "head", "kind": "visual", "node_path": "Body/Head", "parent": "torso", "shape": "circle", "radius": RAGDOLL_HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 },
{ "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "parent": "left_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "parent": "right_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "parent": "left_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "parent": "right_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
]
## Ragdoll joint definitions: one PinJoint2D per non-root body, pinned at the
## child bone's origin. `limit` selects the angular-limit band (neck free,
## shoulder/hip ±160°, elbow/knee 5°..+150°, or its mirrored CCW variant).
const RAGDOLL_JOINTS: Array[Dictionary] = [
{ "child": "head", "parent": "torso", "pin_node_path": "Torso/Head", "limit": "neck" },
{ "child": "left_upper_arm", "parent": "torso", "pin_node_path": "Torso/LeftUpperArm", "limit": "shoulder_hip" },
{ "child": "left_lower_arm", "parent": "left_upper_arm", "pin_node_path": "Torso/LeftUpperArm/LeftLowerArm", "limit": "elbow_knee" },
{ "child": "right_upper_arm", "parent": "torso", "pin_node_path": "Torso/RightUpperArm", "limit": "shoulder_hip" },
{ "child": "right_lower_arm", "parent": "right_upper_arm", "pin_node_path": "Torso/RightUpperArm/RightLowerArm", "limit": "elbow_knee_ccw" },
{ "child": "left_upper_leg", "parent": "torso", "pin_node_path": "Torso/LeftUpperLeg", "limit": "shoulder_hip" },
{ "child": "left_lower_leg", "parent": "left_upper_leg", "pin_node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "elbow_knee_ccw" },
{ "child": "right_upper_leg", "parent": "torso", "pin_node_path": "Torso/RightUpperLeg", "limit": "shoulder_hip" },
{ "child": "right_lower_leg", "parent": "right_upper_leg", "pin_node_path": "Torso/RightUpperLeg/RightLowerLeg", "limit": "elbow_knee" },
]
# ---------------------------------------------------------------------------
# Exported controls
# ---------------------------------------------------------------------------
## Facing preset. Setting it overwrites the four per-joint bend values from
## PROFILE_FLAGS and reorders Body/* children. Default FORWARD.
@export var facing_profile: FacingProfile = FacingProfile.FORWARD:
set(value):
if facing_profile == value:
return
facing_profile = value
if _nodes_ready:
_apply_profile()
## Per-joint bend direction (the actual flip_bend_direction source of truth).
## Defaults match the FORWARD profile. Individually overridable after a profile
## is applied (drifting away from the preset, matching the harness right-click).
@export_group("Bend Direction")
@export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL:
set(value):
left_arm_bend = value
_set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED:
set(value):
right_arm_bend = value
_set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED:
set(value):
left_leg_bend = value
_set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED)
@export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL:
set(value):
right_leg_bend = value
_set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED)
@export_group("Ragdoll Transition")
## How long the ragdoll torso must be at rest before auto-recovery (seconds).
@export var rest_timeout: float = 2.0
## When true, a rested ragdoll automatically stands back up. When false, the
## ragdoll stays down until request_recovery() is called manually.
@export var auto_recover: bool = true
@export_group("Director")
## Walk speed (px/s) used by walk_to when no per-action speed is given.
@export var walk_speed: float = 300.0
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when the facing preset changes (after flags + z-order are applied).
signal facing_profile_changed(profile: int)
## Emitted when a single joint's bend direction changes. `flipped` is the new
## flip_bend_direction value (true == inverted).
signal bend_flag_changed(joint: String, flipped: bool)
## Emitted when the rig's ANIMATED/RAGDOLL state changes. `new_state` carries
## the RigState enum value.
signal state_changed(new_state: int)
# ---------------------------------------------------------------------------
# Director signals (Phase 3a)
# ---------------------------------------------------------------------------
signal arrived(target: Vector2) # walk_to reached its destination
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
signal queue_changed # any queue mutation
signal speech_finished # bubble auto-hid after speak()
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
var _nodes_ready: bool = false
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _torso_bone: Bone2D = null
var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones)
var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK }
# ---------------------------------------------------------------------------
# Rig state (ANIMATED / RAGDOLL)
# ---------------------------------------------------------------------------
var state: RigState = RigState.ANIMATED
var _ragdoll_root: Node2D = null
var _ragdoll_bodies: Dictionary = {} # { String : RigidBody2D }
var _anim_player: AnimationPlayer = null
var _prev_global_pos: Vector2 = Vector2.ZERO
var _prev_global_rot: float = 0.0
var _cached_linear_velocity: Vector2 = Vector2.ZERO
var _cached_angular_velocity: float = 0.0
# ---------------------------------------------------------------------------
# Recovery state
# ---------------------------------------------------------------------------
var _rest_timer: float = 0.0
var _stabilize_timer: float = 0.0
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
var _stand_up_tween: Tween = null
# ---------------------------------------------------------------------------
# Director / navigation state (Phase 3a)
# ---------------------------------------------------------------------------
var action_queue: Array[Dictionary] = []
var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1
var _stop_requested: bool = false
var _nav_agent: NavigationAgent2D = null
## NavigationAgent2D is a plain Node (not Node2D): it has no `position`; it
## derives its agent position from its parent Node2D's global position. This
## anchor sits at the feet (NAV_AGENT_LOCAL_POS) so the agent paths from
## ground level.
var _nav_anchor: Node2D = null
var _walking: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
var _walk_speed_current: float = 300.0
var _walk_mode: String = "nav" # "nav" (follow mesh) | "direct" (off-mesh straight line)
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 _ragdoll_at_rest: bool = false
var _speech_bubble = null # SpeechBubble (preloaded script)
var _speech_active: bool = false
var _speech_time_left: float = 0.0
var _phase_timer: float = 0.0
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Resolve all runtime node references.
_skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if _skeleton == null:
push_warning("StickmanRig: missing '%s' node in rig." % SKELETON_PATH)
_body_container = get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D
if _body_container == null:
push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH)
if _skeleton != null:
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
if _torso_bone == null:
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
_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)
_resolve_bend_modifications()
# Enable the modification stack (IK solves only at runtime).
if _skeleton != null:
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
stack.enabled = true
else:
push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.")
# Apply the authored/default profile once: writes the four mod flags from the
# current var values, reorders Body/* children, and emits the profile signal.
# _nodes_ready is set first so the per-joint setters route their mod updates
# + signal emissions through the live apply path (during instantiation the
# setters only stored values).
_nodes_ready = true
_apply_profile()
# Build the navigation agent at the feet (Phase 3a). It shares the default
# navigation map/layer with the stage's NavigationRegion2D.
# NavigationAgent2D is a plain Node: it has no `position`; it derives its
# agent position from its parent Node2D's global position, so we anchor it
# under a Node2D placed at the feet.
_nav_anchor = Node2D.new()
_nav_anchor.name = "NavigationAgentAnchor"
_nav_anchor.position = NAV_AGENT_LOCAL_POS
add_child(_nav_anchor)
_nav_agent = NavigationAgent2D.new()
_nav_agent.name = "NavigationAgent2D"
_nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE
_nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE
_nav_agent.path_max_distance = 100.0
_nav_agent.max_speed = walk_speed
_nav_agent.avoidance_enabled = false
_nav_anchor.add_child(_nav_agent)
_prev_global_pos = global_position
_prev_global_rot = global_rotation
func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(delta)
_update_walking(delta)
_settle_walk_markers()
_update_speech(delta)
_update_runner(delta)
func _track_momentum(delta: float) -> void:
if delta > 0.0:
_cached_linear_velocity = (global_position - _prev_global_pos) / delta
_cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta
_prev_global_pos = global_position
_prev_global_rot = global_rotation
# ---------------------------------------------------------------------------
# Per-frame rest detection
# ---------------------------------------------------------------------------
## RAGDOLL rest detection: when the torso sits still long enough (and
## auto_recover is on), trigger recovery after a short stabilization delay.
func _update_rest_detection(delta: float) -> void:
if state != RigState.RAGDOLL:
return
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
if torso == null or not is_instance_valid(torso):
return
var at_rest := torso.sleeping \
or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD \
and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
if not at_rest:
_rest_timer = 0.0
_stabilize_timer = 0.0
return
# Publish rest regardless of auto_recover (the director runner polls it).
if not _ragdoll_at_rest:
_ragdoll_at_rest = true
if not auto_recover:
_rest_timer = 0.0
return
_rest_timer += delta
if _rest_timer < rest_timeout:
return
_stabilize_timer += delta
if _stabilize_timer >= STABILIZATION_DELAY:
_start_recovery()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func set_facing_profile(profile: int) -> void:
if not PROFILE_FLAGS.has(profile):
push_warning("StickmanRig: unknown facing profile %d; ignored." % profile)
return
facing_profile = profile
func get_facing_profile() -> int:
return int(facing_profile)
func set_joint_bend_flipped(joint: String, flipped: bool) -> void:
match joint:
"LeftArm":
left_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"RightArm":
right_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"LeftLeg":
left_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
"RightLeg":
right_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL
_:
push_warning("StickmanRig: unknown bend joint '%s'; ignored." % joint)
func get_joint_bend_flipped(joint: String) -> bool:
match joint:
"LeftArm":
return left_arm_bend == BendDirection.INVERTED
"RightArm":
return right_arm_bend == BendDirection.INVERTED
"LeftLeg":
return left_leg_bend == BendDirection.INVERTED
"RightLeg":
return right_leg_bend == BendDirection.INVERTED
_:
push_warning("StickmanRig: unknown bend joint '%s'." % joint)
return false
func get_bend_joints() -> Array[String]:
return BEND_JOINTS
func get_bend_joint_global_position(joint: String) -> Vector2:
var bone := _bend_joint_bones.get(joint) as Bone2D
if bone == null or not is_instance_valid(bone):
push_warning("StickmanRig: unknown or missing bend joint '%s'." % joint)
return Vector2.ZERO
return bone.global_position
func is_in_ragdoll() -> bool:
return state == RigState.RAGDOLL
func set_ragdoll(enabled: bool) -> void:
if enabled:
match state:
RigState.RAGDOLL:
return
RigState.RECOVERING:
_cancel_recovery()
_enter_ragdoll()
_:
_enter_ragdoll()
else:
if state == RigState.RAGDOLL:
_start_recovery()
# else ANIMATED / RECOVERING: no-op
func toggle_ragdoll() -> void:
set_ragdoll(not is_in_ragdoll())
## Public stand-up request. No-op unless the rig is in RAGDOLL.
func request_recovery() -> void:
if state == RigState.RAGDOLL:
_start_recovery()
## Instantly snaps the rig back to its authored standing pose — no stand-up
## 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:
if state == RigState.ANIMATED:
return
if state == RigState.RAGDOLL:
_destroy_ragdoll()
else:
_cancel_recovery()
_restore_standing_markers()
# 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
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
state = RigState.ANIMATED
state_changed.emit(int(state))
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
func _restore_standing_markers() -> void:
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = STAND_POSE[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
## Applies the same velocity delta to every ragdoll body via a mass-scaled
## central impulse, preserving the ragdoll's internal structure. No-op outside
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
func apply_ragdoll_velocity_boost(velocity: Vector2) -> void:
if not is_in_ragdoll():
return
for key: String in _ragdoll_bodies:
var body := _ragdoll_bodies[key] as RigidBody2D
if body != null and is_instance_valid(body):
body.apply_central_impulse(velocity * body.mass)
# ---------------------------------------------------------------------------
# Internal resolution / apply
# ---------------------------------------------------------------------------
func _resolve_bend_modifications() -> void:
_bend_joint_bones.clear()
_bend_modifications.clear()
if _skeleton == null:
return
for joint: String in BEND_JOINTS:
var path: String = BEND_JOINT_BONE_PATHS[joint]
var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D
if bone != null and is_instance_valid(bone):
_bend_joint_bones[joint] = bone
else:
push_warning("StickmanRig: missing bend-joint bone '%s'." % path)
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack == null:
return
for i: int in stack.modification_count:
var mod := stack.get_modification(i)
if not (mod is SkeletonModification2DTwoBoneIK):
continue
var ik := mod as SkeletonModification2DTwoBoneIK
for joint: String in BEND_JOINTS:
if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]):
_bend_modifications[joint] = ik
break
for joint: String in BEND_JOINTS:
if not _bend_modifications.has(joint):
push_warning("StickmanRig: missing TwoBoneIK modification for bend joint '%s'." % joint)
## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters,
## so the live mods stay in sync), reorders Body/* children, then emits
## facing_profile_changed.
func _apply_profile() -> void:
var flags: Dictionary = PROFILE_FLAGS.get(facing_profile, PROFILE_FLAGS[FacingProfile.FORWARD])
left_arm_bend = BendDirection.INVERTED if bool(flags.get("LeftArm", false)) else BendDirection.NORMAL
right_arm_bend = BendDirection.INVERTED if bool(flags.get("RightArm", false)) else BendDirection.NORMAL
left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL
right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL
_apply_body_z_order()
_apply_head_flip()
facing_profile_changed.emit(int(facing_profile))
func _apply_head_flip() -> void:
var head := get_node_or_null("Skeleton2D/Torso/Head") as Node2D
var pivot := get_node_or_null("Skeleton2D/Torso/Head/Pivot") as Node2D
if pivot != null:
var is_left := (facing_profile == FacingProfile.LEFT)
if is_left:
# Mirror local X and invert double the bone's rotation to mirror in world space
pivot.scale = Vector2(-1.0, 1.0)
pivot.rotation = -1.0 * head.rotation
else:
pivot.scale = Vector2(1.0, 1.0)
pivot.rotation = 0.0
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
## setter only stored the backing value during instantiation).
func _set_joint_bend_inverted(joint: String, inverted: bool) -> void:
if not _nodes_ready:
return
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod != null:
mod.flip_bend_direction = inverted
bend_flag_changed.emit(joint, inverted)
## Task 2 algorithm: walk the profile's ordered part names back-to-front and
## move_child(part, count - 1) each existing part; unknown/extra children stay
## at the back; missing parts skipped silently.
func _apply_body_z_order() -> void:
if _body_container == null or not is_instance_valid(_body_container):
return
var order: Array = Z_ORDER_BY_PROFILE.get(facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
for part_name: String in order:
var part := _body_container.get_node_or_null(NodePath(part_name))
if part != null:
_body_container.move_child(part, _body_container.get_child_count() - 1)
# ---------------------------------------------------------------------------
# Ragdoll translation (kinematic -> physics)
# ---------------------------------------------------------------------------
func _enter_ragdoll() -> void:
if _skeleton == null or _body_container == null:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
return
_cancel_walking()
_ragdoll_at_rest = false
# Instant handoff: stop the player without resetting it (keep_state) and
# build the ragdoll from the CURRENT solved bone positions while the IK
# stack is still enabled (disabling it first would revert the bones to the
# authored rest pose). Body/* is then hidden immediately and IK disabled —
# no crossfade, because the ragdoll is spawned at exactly the same pose, so
# a fade would only read as ghosting.
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop(true)
_build_ragdoll()
if is_instance_valid(_body_container):
_body_container.visible = false
_body_container.modulate.a = 1.0
if _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = false
state = RigState.RAGDOLL
state_changed.emit(int(state))
_rest_timer = 0.0
_stabilize_timer = 0.0
# ---------------------------------------------------------------------------
# Recovery (ragdoll -> kinematic stand-up)
# ---------------------------------------------------------------------------
## Captures every ragdoll body's global transform into rig-local space, plus
## each capsule's half-length (from build-time metadata) so the snap can derive
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
func _capture_ragdoll_pose() -> void:
_captured_pose.clear()
for key: String in _ragdoll_bodies:
var body := _ragdoll_bodies[key] as RigidBody2D
if body == null or not is_instance_valid(body):
continue
_captured_pose[key] = {
"pos": to_local(body.global_position),
"rot": body.global_rotation - global_rotation,
"half": float(body.get_meta("half_height", 0.0)),
}
func _start_recovery() -> void:
_capture_ragdoll_pose()
_destroy_ragdoll()
state = RigState.RECOVERING
state_changed.emit(int(state))
_snap_skeleton_to_pose()
_play_stand_up()
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
## clean slate.
func _cancel_recovery() -> void:
if _stand_up_tween != null and _stand_up_tween.is_valid():
_stand_up_tween.kill()
_stand_up_tween = null
## Marker-driven kinematic snap: writes the captured pose onto the 6 IK-target
## markers (NOT the Torso Bone2D, which is slaved to its marker via
## RemoteTransform2D), then re-enables IK so TwoBoneIK solves the limbs toward
## the captured end-effectors.
##
## Geometry notes: the ragdoll capsules span joint origin -> tip along their
## +X (body.rotation IS the segment direction), so the real joints are at
## center ± direction * half_height. The Torso marker rotation must also
## subtract the Torso Bone2D's `bone_angle` (bone world angle = marker rotation
## + bone_angle); using the body rotation directly would slam the whole
## skeleton -90° and lay the figure flat.
func _snap_skeleton_to_pose() -> void:
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
var torso_pose: Dictionary = _captured_pose.get("torso", {})
if not torso_pose.is_empty():
var spine_dir := Vector2.from_angle(torso_pose.get("rot", 0.0))
var half := float(torso_pose.get("half", 0.0))
var bone_angle_rad := 0.0
if _torso_bone != null:
bone_angle_rad = deg_to_rad(_torso_bone.bone_angle)
# Hip = spine bottom end of the torso capsule.
torso_marker.position = torso_pose.get("pos", torso_marker.position) - spine_dir * half
torso_marker.rotation = torso_pose.get("rot", 0.0) - bone_angle_rad
var head_marker := _get_ik_marker("Head")
if head_marker != null:
var head_pose: Dictionary = _captured_pose.get("head", {})
if not head_pose.is_empty():
head_marker.position = head_pose.get("pos", head_marker.position)
_set_marker_from_body("Left_Hand", "left_lower_arm")
_set_marker_from_body("Right_Hand", "right_lower_arm")
_set_marker_from_body("Left_Leg", "left_lower_leg")
_set_marker_from_body("Right_Leg", "right_lower_leg")
# Show the kinematic puppet first so it appears already in the captured
# pose, then re-enable IK to solve toward the end-effector markers.
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
var marker := _get_ik_marker(marker_name)
if marker == null:
return
var pose: Dictionary = _captured_pose.get(body_key, {})
if pose.is_empty():
return
# Far end (wrist / ankle) = body center + segment direction * half.
var dir := Vector2.from_angle(pose.get("rot", 0.0))
var half := float(pose.get("half", 0.0))
marker.position = pose.get("pos", marker.position) + dir * half
func _get_ik_marker(name: String) -> Marker2D:
var path: String = IK_TARGET_PATHS.get(name, "")
if path.is_empty():
return null
return get_node_or_null(NodePath(path)) as Marker2D
## Stand-up: tweens the 6 IK markers from the captured pose to STAND_POSE
## (sine ease-in-out). No baked animation — a fixed first keyframe can never
## match an arbitrary ragdoll rest pose, so the tween starts from wherever the
## snap left the markers.
func _play_stand_up() -> void:
_stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION)
if _stand_up_tween != null:
_stand_up_tween.finished.connect(_on_stand_up_finished)
## Tweens the 6 IK markers from their current (captured) values to the target
## pose over `duration` (sine ease-in-out), all in parallel.
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
var tween := create_tween()
tween.set_parallel(true)
tween.set_trans(Tween.TRANS_SINE)
tween.set_ease(Tween.EASE_IN_OUT)
for marker_name: String in target_pose:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = target_pose[marker_name]
tween.tween_property(marker, "position", target.get("pos", marker.position), duration)
if marker_name == "Torso":
tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration)
return tween
## Stand-up tween complete: settle into ANIMATED.
func _on_stand_up_finished() -> void:
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
state = RigState.ANIMATED
state_changed.emit(int(state))
func _build_ragdoll() -> void:
var parent: Node = get_parent()
if parent == null:
parent = get_tree().current_scene
if parent == null:
push_warning("StickmanRig: cannot reparent ragdoll container; no parent or current scene.")
return
_ragdoll_root = Node2D.new()
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME
parent.add_child(_ragdoll_root)
_ragdoll_bodies.clear()
for entry: Dictionary in RAGDOLL_BODIES:
_build_ragdoll_body(entry)
for entry: Dictionary in RAGDOLL_JOINTS:
_build_ragdoll_joint(entry)
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
if torso != null:
torso.linear_velocity = _cached_linear_velocity
torso.angular_velocity = _cached_angular_velocity
func _build_ragdoll_body(entry: Dictionary) -> void:
var key: String = entry["key"]
var body := RigidBody2D.new()
body.name = "Ragdoll_" + key
body.mass = float(entry["mass"])
body.linear_damp = float(entry["linear_damp"])
body.angular_damp = float(entry["angular_damp"])
body.gravity_scale = 1.0
body.lock_rotation = false
body.freeze = false
body.collision_layer = 1
body.collision_mask = 1
var shape := CollisionShape2D.new()
shape.name = "CollisionShape2D"
if entry["kind"] == "visual":
var visual := get_node_or_null(NodePath(entry["node_path"])) as Node2D
if visual == null or not is_instance_valid(visual):
push_warning("StickmanRig: missing ragdoll visual node '%s'." % entry["node_path"])
body.queue_free()
return
var circle := CircleShape2D.new()
circle.radius = float(entry["radius"])
shape.shape = circle
body.add_child(shape)
body.position = visual.global_position
body.rotation = 0.0
body.set_meta("half_height", 0.0)
_add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR)
else:
var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D
if bone == null or not is_instance_valid(bone):
push_warning("StickmanRig: missing ragdoll bone '%s'." % entry["node_path"])
body.queue_free()
return
var origin: Vector2 = bone.global_position
var tip: Vector2
if key == "torso":
var head_bone := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head_bone == null or not is_instance_valid(head_bone):
push_warning("StickmanRig: missing Head bone for torso ragdoll body.")
body.queue_free()
return
tip = head_bone.global_position
else:
# A Bone2D's length runs along its local +X rotated by `bone_angle`
# (stored in degrees). to_global(Vector2(length, 0)) alone ignores
# bone_angle, so rotate the tip vector by it to reach the real
# far-end joint (which coincides with the child bone's origin).
tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
var length: float = origin.distance_to(tip)
var midpoint: Vector2 = (origin + tip) * 0.5
var capsule := CapsuleShape2D.new()
capsule.height = length
capsule.radius = float(entry["radius"])
shape.shape = capsule
# CapsuleShape2D spans local +Y, but a Bone2D's length runs along local
# +X: rotate the shape -90° so the capsule aligns with the body's +X,
# which we point along the bone's origin->tip direction below.
shape.rotation = -PI / 2.0
body.add_child(shape)
body.position = midpoint
body.rotation = (tip - origin).angle()
# Half the capsule's length along the body's +X — lets recovery derive
# the joint ends (hip/wrist/ankle) from the body center at capture time.
body.set_meta("half_height", length * 0.5)
_add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR)
_ragdoll_root.add_child(body)
_ragdoll_bodies[key] = body
## Visible capsule mesh (Line2D with round caps) spanning the body's local +X,
## which `_build_ragdoll_body` already aligns with the bone's origin->tip
## direction. Collision shapes never render in-game, so this is what the player
## actually sees in RAGDOLL mode.
func _add_ragdoll_visual_capsule(body: RigidBody2D, length: float, radius: float, color: Color) -> void:
var line := Line2D.new()
line.name = "VisualCapsule"
line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)])
line.width = radius * 2.0
line.default_color = color
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
line.end_cap_mode = Line2D.LINE_CAP_ROUND
line.joint_mode = Line2D.LINE_JOINT_ROUND
body.add_child(line)
## Visible filled circle for the head body, matching the authored head circle.
func _add_ragdoll_visual_circle(body: RigidBody2D, radius: float, color: Color) -> void:
var poly := Polygon2D.new()
poly.name = "VisualCircle"
var points := PackedVector2Array()
for i: int in RAGDOLL_CIRCLE_SEGMENTS:
var angle: float = TAU * float(i) / float(RAGDOLL_CIRCLE_SEGMENTS)
points.append(Vector2(cos(angle), sin(angle)) * radius)
poly.polygon = points
poly.color = color
body.add_child(poly)
func _build_ragdoll_joint(entry: Dictionary) -> void:
var child_key: String = entry["child"]
var parent_key: String = entry["parent"]
var child_body := _ragdoll_bodies.get(child_key) as RigidBody2D
var parent_body := _ragdoll_bodies.get(parent_key) as RigidBody2D
if child_body == null or parent_body == null:
return
var pin_bone := _skeleton.get_node_or_null(NodePath(entry["pin_node_path"])) as Bone2D
if pin_bone == null or not is_instance_valid(pin_bone):
push_warning("StickmanRig: missing pin bone '%s' for ragdoll joint '%s'." % [entry["pin_node_path"], child_key])
return
var pin := PinJoint2D.new()
pin.name = "RagdollPin_" + child_key
pin.position = pin_bone.global_position
_ragdoll_root.add_child(pin)
pin.node_a = pin.get_path_to(parent_body)
pin.node_b = pin.get_path_to(child_body)
pin.softness = RAGDOLL_TARGET_SOFTNESS
_apply_ragdoll_joint_limits(pin, entry["limit"])
func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void:
match limit:
"elbow_knee":
# Fold (natural bend) toward +CW, resist hyperextension past -5°.
pin.angular_limit_enabled = true
pin.angular_limit_lower = -deg_to_rad(5.0)
pin.angular_limit_upper = deg_to_rad(150.0)
"elbow_knee_ccw":
# Mirrored limb: its natural bend folds -CCW (the rig's TwoBoneIK
# bend flag for this limb is inverted), so the large allowance goes
# on the negative side and hyperextension is capped at +5°.
pin.angular_limit_enabled = true
pin.angular_limit_lower = -deg_to_rad(150.0)
pin.angular_limit_upper = deg_to_rad(5.0)
"shoulder_hip":
pin.angular_limit_enabled = true
pin.angular_limit_lower = -deg_to_rad(160.0)
pin.angular_limit_upper = deg_to_rad(160.0)
_:
pin.angular_limit_enabled = false
func _destroy_ragdoll() -> void:
if _ragdoll_root != null and is_instance_valid(_ragdoll_root):
# Retire the name immediately so a same-frame _build_ragdoll (e.g.
# set_ragdoll(true) during RECOVERING) does not get its fresh container
# auto-renamed by Godot's sibling-name de-duplication while the old one
# is still awaiting its deferred queue_free().
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME + "_retired"
_ragdoll_root.queue_free()
_ragdoll_root = null
_ragdoll_bodies.clear()
# ---------------------------------------------------------------------------
# Navigation / walking (Phase 3a)
# ---------------------------------------------------------------------------
## Start walking so the feet land at `target` (world/ground space). `speed <= 0`
## uses walk_speed. No-op (push_warning) unless state == ANIMATED.
func walk_to(target: Vector2, speed: float = -1.0) -> void:
if state != RigState.ANIMATED:
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
return
_walk_target_feet = target
_walk_speed_current = speed if speed > 0.0 else walk_speed
_nav_agent.max_speed = _walk_speed_current
_nav_agent.target_position = target
_walk_dbg("walk_to target=(%.1f, %.1f) speed=%.1f root=(%.1f, %.1f) feet=(%.1f, %.1f)" % [
target.x, target.y, _walk_speed_current,
global_position.x, global_position.y,
_nav_anchor.global_position.x, _nav_anchor.global_position.y,
])
var dx := target.x - global_position.x
var anim_name: String
if dx < -0.5:
set_facing_profile(FacingProfile.LEFT)
anim_name = "walk_left"
elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT)
anim_name = "walk_right"
else:
anim_name = "walk_right"
if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.has_animation(anim_name):
_anim_player.play(anim_name)
_walking = true
_walk_done = false
# The nav/direct steering mode is latched once per walk (on the first
# post-sync frame) so it cannot flip between frames and oscillate the rig.
_walk_mode = "nav"
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
func is_walking() -> bool:
return _walking
func _update_walking(delta: float) -> void:
if not _walking:
return
if state != RigState.ANIMATED:
_cancel_walking()
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).
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
_walk_dbg("sync pending")
return
# Latch the steering mode once the map is synced, so a waypoint that sits
# near the mesh boundary can't flip nav<->direct between frames (that flip
# swaps between two vertically-offset targets and reads as up/down jitter).
# Reachability only becomes meaningful a frame or two AFTER the map syncs and
# a forced path query round-trips, so probe for up to LATCH_PROBE_MAX_FRAMES:
# latch "nav" as soon as the agent reports the target reachable; if it never
# does within the bound (genuinely off-mesh), latch "direct".
if not _walk_mode_latched:
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
_walk_latch_probe_frames += 1
_nav_agent.get_next_path_position()
if _nav_agent.is_target_reachable():
_walk_mode_latched = true
_walk_mode = "nav"
_walk_dbg("latch mode=nav (probe %d)" % _walk_latch_probe_frames)
else:
_walk_dbg("latch probe %d (not reachable yet)" % _walk_latch_probe_frames)
return
else:
_walk_mode_latched = true
_walk_mode = "direct"
_walk_dbg("latch mode=direct (probe bound reached)")
# Ask the agent for its next waypoint FIRST. This forces the agent's internal
# path update (_update_navigation), which re-queries the map whenever the
# stored path is empty (set_target_position resets it via _request_repath).
# The read-only get_current_navigation_path() accessor alone never triggers a
# 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)
var root_target: Vector2
if _walk_mode == "nav":
# Terminate once the nav agent reports its path complete AND the rig is
# close enough: the agent finishes at target_desired_distance (12 px,
# 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
_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
else:
root_target = next_feet + FOOT_OFFSET
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
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
_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" % [
Engine.get_physics_frames(),
_current_index,
_walk_mode,
global_position.x, global_position.y,
_nav_anchor.global_position.x, _nav_anchor.global_position.y,
_walk_target_feet.x, _walk_target_feet.y,
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
str(_nav_agent.is_navigation_finished()),
str(_nav_agent.is_target_reachable()),
_nav_agent.get_final_position().x, _nav_agent.get_final_position().y,
_nav_agent.get_current_navigation_path().size(),
next_feet.x, next_feet.y,
NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()),
])
## Re-asserts the standing pose one extra physics frame after a walk ends, so
## any residual walk-animation body bob (a ±12.5 px torso keyframe) is not left
## on the markers when the animation stop and marker restore race.
func _settle_walk_markers() -> void:
if _walk_settle_frames > 0:
_walk_settle_frames -= 1
_restore_standing_markers()
func _finish_walk(reason: String = "") -> void:
if DEBUG_WALK:
var map_iter := -1
if _nav_agent != null:
map_iter = NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map())
_walk_dbg("finish reason=%s root=(%.1f, %.1f) dist_to_target=%.1f map_iter=%d" % [
reason,
global_position.x, global_position.y,
global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
map_iter,
])
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_restore_standing_markers()
_walk_settle_frames = 1
_walk_done = true
_walking = false
arrived.emit(_walk_target_feet)
func _cancel_walking() -> void:
_walk_dbg("cancel (state=%s)" % RigState.keys()[state])
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
_walk_done = false
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
# ---------------------------------------------------------------------------
# Speech (Phase 3a)
# ---------------------------------------------------------------------------
## Show the speech bubble with `text` for `duration` seconds; auto-hides and
## emits speech_finished. Lazily creates the SpeechBubble child on first use.
func speak(text: String, duration: float) -> void:
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new()
_speech_bubble.name = "SpeechBubble"
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
add_child(_speech_bubble)
_speech_bubble.show_text(text)
_speech_active = true
_speech_time_left = maxf(duration, 0.0)
func _update_speech(delta: float) -> void:
if not _speech_active:
return
_speech_time_left -= delta
if _speech_time_left <= 0.0:
_hide_speech()
speech_finished.emit()
func _hide_speech() -> void:
_speech_active = false
_speech_time_left = 0.0
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
# ---------------------------------------------------------------------------
# Action queue (Phase 3a)
# ---------------------------------------------------------------------------
func queue_action(action: Dictionary) -> void:
action_queue.append(action)
queue_changed.emit()
func clear_queue() -> void:
action_queue.clear()
queue_changed.emit()
func get_queue() -> Array[Dictionary]:
return action_queue.duplicate()
func remove_action(index: int) -> void:
if index < 0 or index >= action_queue.size():
push_warning("StickmanRig: remove_action index %d out of range." % index)
return
action_queue.remove_at(index)
queue_changed.emit()
func insert_action(index: int, action: Dictionary) -> void:
action_queue.insert(clampi(index, 0, action_queue.size()), action)
queue_changed.emit()
func queue_size() -> int:
return action_queue.size()
## Append `actions` to the queue and, when the runner is idle, resume execution
## at the first newly-appended action WITHOUT replaying the existing queue.
## Used by the Phase 4 event engine to inject reactive actions onto a stickman.
func enqueue_reactive(actions: Array[Dictionary]) -> void:
if actions.is_empty():
return
for action: Dictionary in actions:
action["reactive"] = true
var start := action_queue.size()
action_queue.append_array(actions)
queue_changed.emit()
if _runner_state == RunnerState.IDLE:
# Resume at the first appended action (not at index 0), so any queued
# sequential actions are skipped over rather than replayed.
_current_index = start - 1
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_stop_requested = false
## Drops rule-injected ("reactive") actions from the queue, restoring the
## authored sequential queue after a Play session. No-op while the runner is
## executing (callers invoke it on mode exit, after stop_queue()).
func clear_reactive_actions() -> void:
if _runner_state == RunnerState.EXECUTING:
return
var kept: Array[Dictionary] = []
for action: Dictionary in action_queue:
if not bool(action.get("reactive", false)):
kept.append(action)
if kept.size() != action_queue.size():
action_queue = kept
queue_changed.emit()
# ---------------------------------------------------------------------------
# Runner state machine (Phase 3a)
# ---------------------------------------------------------------------------
## Start running the queue. Empty queue emits queue_finished immediately.
func start_queue() -> void:
if _runner_state == RunnerState.EXECUTING:
return
if DEBUG_WALK:
print("[runner] start_queue size=%d" % action_queue.size())
if action_queue.is_empty():
queue_finished.emit()
return
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_current_index = -1
_stop_requested = false
## Abort the current action and return to IDLE. Does not emit queue_finished.
func stop_queue() -> void:
_stop_requested = true
_cancel_walking()
_hide_speech()
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
func is_queue_running() -> bool:
return _runner_state == RunnerState.EXECUTING
func is_ragdoll_at_rest() -> bool:
return _ragdoll_at_rest
func _update_runner(delta: float) -> void:
if _runner_state != RunnerState.EXECUTING:
return
match _action_phase:
ActionPhase.NONE:
_advance_to_next_action()
ActionPhase.WALKING:
if _walk_done:
_finish_action()
ActionPhase.SPEAKING:
if not _speech_active:
_finish_action()
ActionPhase.WAITING:
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_action()
ActionPhase.RAGDOLLING:
if is_ragdoll_at_rest():
_finish_action()
ActionPhase.RECOVERING:
if state == RigState.ANIMATED:
_finish_action()
func _advance_to_next_action() -> void:
_current_index += 1
if _current_index >= action_queue.size():
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
queue_finished.emit()
return
var action := action_queue[_current_index]
if DEBUG_WALK:
print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))])
action_started.emit(action, _current_index)
_begin_action(action)
func _begin_action(action: Dictionary) -> void:
match String(action.get("type", "")):
"walk_to":
_action_phase = ActionPhase.WALKING
_walk_done = false
walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0)))
if not _walking:
# walk_to self-guarded (e.g. not ANIMATED): complete immediately
# so the runner never hangs.
_walk_done = true
"speak":
_action_phase = ActionPhase.SPEAKING
speak(String(action.get("text", "")), float(action.get("duration", 2.0)))
"wait":
_phase_timer = float(action.get("duration", 0.0))
_action_phase = ActionPhase.WAITING
"ragdoll":
_action_phase = ActionPhase.RAGDOLLING
set_ragdoll(true)
"recover":
_action_phase = ActionPhase.RECOVERING
request_recovery()
_:
push_warning("StickmanRig: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
func _finish_action() -> void:
if DEBUG_WALK and _current_index >= 0 and _current_index < action_queue.size():
print("[runner] finish idx=%d type=%s" % [_current_index, String(action_queue[_current_index].get("type", ""))])
if _current_index >= 0 and _current_index < action_queue.size():
action_finished.emit(action_queue[_current_index], _current_index)
_action_phase = ActionPhase.NONE