Files
stickman/scripts/stickman_rig.gd
T
ryan 63c64bd529 Refactor ragdoll recovery system: implement joint bend limits and recovery path overrides
- Updated `DEBUG_RECOVERY` to false for production.
- Introduced `RAGDOLL_JOINT_BAND_LIMITS` for customizable joint bend limits based on facing profile.
- Added `RECOVERY_LEG_SIDE_FORCE` for manual control of leg IK bend direction during recovery.
- Implemented `RECOVERY_JOINT_PATH` to allow optional tween path overrides for elbow/knee recovery.
- Removed debug print statements related to recovery visuals to clean up output.
- Adjusted `_apply_ragdoll_joint_limits` to utilize new bend limits configuration.
- Enhanced documentation in `ragdoll-cleanup.md` to reflect changes in the recovery pipeline and tuning tables.
2026-09-10 01:09:30 -04:00

2103 lines
90 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": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
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
## Ground probe (recovery): the ray fans 7 X-offsets around the landing X,
## starts this far above the ragdoll's topmost point and ends this far below
## its bottommost point.
const GROUND_PROBE_START_OFFSET := 50.0
const GROUND_PROBE_END_OFFSET := 1500.0
## A hit is accepted only inside this band around the ragdoll's top/bottom
## extent (rejects overhang hits above and gap hits far below).
const GROUND_PROBE_TOLERANCE := 200.0
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
## Upper + lower Bone2D paths (Skeleton2D-relative) per bend joint. Used to
## drive the limb bones directly during recovery while the TwoBoneIK mods are
## disabled (the IK re-solve would otherwise snap limbs to the profile's
## preferred bend solution instead of the captured ragdoll pose).
const LIMB_BONE_PATHS: Dictionary = {
"LeftArm": ["Torso/LeftUpperArm", "Torso/LeftUpperArm/LeftLowerArm"],
"RightArm": ["Torso/RightUpperArm", "Torso/RightUpperArm/RightLowerArm"],
"LeftLeg": ["Torso/LeftUpperLeg", "Torso/LeftUpperLeg/LeftLowerLeg"],
"RightLeg": ["Torso/RightUpperLeg", "Torso/RightUpperLeg/RightLowerLeg"],
}
## Ragdoll body keys (upper, lower) per bend joint.
const LIMB_BODY_KEYS: Dictionary = {
"LeftArm": ["left_upper_arm", "left_lower_arm"],
"RightArm": ["right_upper_arm", "right_lower_arm"],
"LeftLeg": ["left_upper_leg", "left_lower_leg"],
"RightLeg": ["right_upper_leg", "right_lower_leg"],
}
# ---------------------------------------------------------------------------
# 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 := 2.0
## 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
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
const DEBUG_RECOVERY := 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", "visual_path": "Body/Body", "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", "visual_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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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" },
]
## ---------------------------------------------------------------------------
## Joint bend configuration (EDIT ME)
## ---------------------------------------------------------------------------
## Every per-joint bend decision in the ragdoll cycle lives in these tables,
## keyed by facing profile, so each joint can be tuned individually without
## touching the pipeline code.
##
## RAGDOLL_JOINT_BAND_LIMITS[profile][joint] — the fall's knee/elbow PinJoint2D
## angular band, in DEGREES, as { "lower": ..., "upper": ... }:
## - The band is the allowed range of the joint's relative angle
## (shin vs thigh / forearm vs upper arm), measured in the pin's frame.
## - 0 = straight. Negative = folds one way; positive = folds the other.
## - Natural shape: a SMALL side (~5) that resists hyperextension the wrong
## way, and a LARGE side (~150) on the fold direction.
## - To reverse a joint's bend: swap the magnitudes, e.g.
## { "lower": -5, "upper": 150 } <-> { "lower": -150, "upper": 5 }.
## - lower must be <= upper (the joint angle must stay inside the band).
## Missing joint entries fall back to the RAGDOLL_JOINTS `limit` string.
const RAGDOLL_JOINT_BAND_LIMITS: Dictionary = {
FacingProfile.FORWARD: {
"left_lower_arm": { "lower": -150.0, "upper": 5.0 },
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": -5.0, "upper": 150.0 },
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
},
FacingProfile.LEFT: {
"left_lower_arm": { "lower": 60.0, "upper": 100.0 },
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": -100.0, "upper": -90.0 },
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
},
FacingProfile.RIGHT: {
"left_lower_arm": { "lower": -100.0, "upper": -60.0 },
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": 90.0, "upper": 100.0 },
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
},
}
## RECOVERY_LEG_SIDE_FORCE[profile][leg] — optional manual override of the leg
## IK `flip_bend_direction` during the stand-up (true = inverted). A missing
## key means automatic: match the captured fall pose's knee side.
const RECOVERY_LEG_SIDE_FORCE: Dictionary = {
FacingProfile.FORWARD: {},
FacingProfile.LEFT: {},
FacingProfile.RIGHT: {},
}
## RECOVERY_JOINT_PATH[profile][joint] — optional override of the elbow/knee
## stand-up tween path: "shortest" = plain shortest rotation. A missing key
## means automatic: pass through straight when the shortest path would cross
## the +/-180 (fully-backward) boundary.
const RECOVERY_JOINT_PATH: Dictionary = {
FacingProfile.FORWARD: {},
FacingProfile.LEFT: {},
FacingProfile.RIGHT: {},
}
# ---------------------------------------------------------------------------
# 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 _head_bone: Bone2D = null
var _head_look_at: SkeletonModification2DLookAt = 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 _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
var _stand_up_tween: Tween = null
var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } }
var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up
var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recovery's END target
var _probe_top_y: float = 0.0 # ground-probe context retained for the ankle clamp
var _probe_bottom_y: float = 0.0
var _probe_exclude: Array[RID] = []
# ---------------------------------------------------------------------------
# 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.")
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if _head_bone == null:
push_warning("StickmanRig: missing 'Torso/Head' 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()
_resolve_limb_bones()
# Enable the modification stack (IK solves only at runtime).
if _skeleton != null:
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
stack.enabled = true
pass
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)
_pin_mirrored_head_rotation()
## Re-asserts the head bone's FORWARD canonical aim rotation each frame while
## facing LEFT and ANIMATED. The LookAt modification is disabled in that state
## (_apply_head_lookat_mirror_mode) so nothing else rewrites the bone. During
## RECOVERING the pin is skipped: the stand-up tween drives the head bone
## itself, and the LookAt is disabled, so a per-frame pin would fight the tween.
func _pin_mirrored_head_rotation() -> void:
if facing_profile != FacingProfile.LEFT or state != RigState.ANIMATED:
return
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
if _head_look_at.enabled:
_head_look_at.enabled = false
if _head_bone != null and is_instance_valid(_head_bone):
_head_bone.rotation = 0
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
_set_limb_mods_enabled(true)
_restore_head_lookat_state()
_recovery_bones.clear()
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
_head_look_at = null
for i: int in stack.modification_count:
var mod := stack.get_modification(i)
if mod is SkeletonModification2DLookAt:
_head_look_at = mod as SkeletonModification2DLookAt
continue
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)
## Resolves the 8 limb Bone2D nodes (upper + lower per bend joint). Called from
## _ready() so recovery can drive the bones directly while the TwoBoneIK mods
## are disabled.
func _resolve_limb_bones() -> void:
_limb_bones.clear()
if _skeleton == null:
return
for joint: String in LIMB_BONE_PATHS:
var paths: Array = LIMB_BONE_PATHS[joint]
var upper := _skeleton.get_node_or_null(NodePath(paths[0])) as Bone2D
var lower := _skeleton.get_node_or_null(NodePath(paths[1])) as Bone2D
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
push_warning("StickmanRig: missing limb bones for bend joint '%s'." % joint)
continue
_limb_bones[joint] = { "upper": upper, "lower": lower }
## 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()
# Whole-rig Y-axis mirror (spec §9a): facing LEFT mirrors the entire figure
# -- head + body + IK targets + mounted Body/* geometry -- so the head AND
# body face the correct direction together. RIGHT/FORWARD keep an identity
# scale. An X-mirror does not affect depth (draw order), so
# Z_ORDER_BY_PROFILE is unchanged.
scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0)
_apply_head_lookat_mirror_mode()
facing_profile_changed.emit(int(facing_profile))
## The SkeletonModification2DLookAt that aims the Head bone is NOT
## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the
## root's negative X scale reflects the head-bone frame, and the LookAt writes a
## bone rotation 180 degrees off the FORWARD aim (PI -> 0 headless). Because the
## mounted head geometry is offset from Body/Head's local origin (the chin sits
## at the neck and the head extends away from it), that 180-degree error flips
## the head to hang BELOW the neck instead of sitting above it -- the whole-rig
## mirror then displaces the head ~2x its mount offset relative to the torso.
## Fix: when facing LEFT, disable the LookAt and pin the head bone to the
## FORWARD canonical aim rotation (PI), so the head is a rigid mirror of the
## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted
## every physics frame while the kinematic puppet is live (ANIMATED/RECOVERING)
## because re-enabling the IK stack during recovery would otherwise let the
## LookAt flip the bone again.
func _apply_head_lookat_mirror_mode() -> void:
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
var mirrored := facing_profile == FacingProfile.LEFT
_head_look_at.enabled = false#not mirrored
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
_head_bone.rotation = PI
elif not mirrored:
_head_bone.rotation = 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)
# ---------------------------------------------------------------------------
## Captures the kinematic puppet's current state at the moment ragdoll is
## entered: the 6 IK-target marker values, the 8 limb bone rotations, the head
## bone rotation, the facing profile, the 4 live bend flags, and the head
## LookAt mod's enabled state. This snapshot is the recovery tween's END target
## — the figure returns to the exact pose it had just before the fall (which
## may be a mid-walk pose); the animation is not resumed.
func _capture_pre_ragdoll_state() -> void:
_pre_ragdoll_state.clear()
var markers := {}
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker != null:
markers[marker_name] = { "pos": marker.position, "rot": marker.rotation }
_pre_ragdoll_state["markers"] = markers
var limb_rotations := {}
for joint: String in _limb_bones:
var pair: Dictionary = _limb_bones[joint]
var upper: Bone2D = pair.get("upper")
var lower: Bone2D = pair.get("lower")
limb_rotations[joint] = {
"upper": upper.rotation if upper != null and is_instance_valid(upper) else 0.0,
"lower": lower.rotation if lower != null and is_instance_valid(lower) else 0.0,
}
_pre_ragdoll_state["limb_rotations"] = limb_rotations
_pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null and is_instance_valid(_head_bone) else 0.0
_pre_ragdoll_state["facing_profile"] = int(facing_profile)
var bends := {}
for joint: String in BEND_JOINTS:
bends[joint] = get_joint_bend_flipped(joint)
_pre_ragdoll_state["bends"] = bends
_pre_ragdoll_state["head_lookat_enabled"] = _head_look_at.enabled if _head_look_at != null and is_instance_valid(_head_look_at) else false
# Per-leg knee side (sign of the hip->ankle vs hip->knee cross) at entry,
# used at the snap to set the leg IK bend flags so the re-solve keeps the
# captured knee side instead of flipping to the mirrored solution.
var leg_sides := {}
for leg_joint: String in ["LeftLeg", "RightLeg"]:
var pair: Dictionary = _limb_bones.get(leg_joint, {})
var upper: Bone2D = pair.get("upper")
var lower: Bone2D = pair.get("lower")
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
continue
var marker_name: String = "Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg"
var ankle_marker := _get_ik_marker(marker_name)
if ankle_marker == null:
continue
var hip := upper.global_position
var knee := lower.global_position
var ankle := ankle_marker.global_position
var side := (ankle - hip).cross(knee - hip)
leg_sides[leg_joint] = { "side": 1.0 if side >= 0.0 else -1.0, "flag": get_joint_bend_flipped(leg_joint) }
_pre_ragdoll_state["leg_sides"] = leg_sides
# Chain-space offsets that map captured ragdoll angles onto the node chains:
# the torso marker's entry rotation relative to the entry spine direction,
# and the head bone's entry chain relative to the head body's entry angle
# (the head ragdoll body spawns with rotation 0). Captured here so the
# recovery snap needs no authored geometry constants, and the mirrored
# root's ambiguous global_rotation decomposition (+/-PI) cancels out.
var torso_entry_rot := 0.0
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
torso_entry_rot = torso_marker.rotation
var spine_entry_rel := 0.0
if _head_bone != null and _torso_bone != null and is_instance_valid(_head_bone) and is_instance_valid(_torso_bone):
spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - global_rotation
_pre_ragdoll_state["torso_chain_offset"] = _chain_angle_from_captured(spine_entry_rel) - torso_entry_rot
var head_entry_chain := torso_entry_rot + float(_pre_ragdoll_state.get("head_rotation", 0.0))
_pre_ragdoll_state["head_chain_offset"] = _chain_angle_from_captured(-global_rotation) - head_entry_chain
func _enter_ragdoll() -> void:
if _skeleton == null or _body_container == null:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
return
_capture_pre_ragdoll_state()
_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()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
# While capturing, also compute the ragdoll's full top/bottom extent and
# collect every body's RID so the ground probe can raycast the terrain under
# the fallen figure while excluding the ragdoll's own collision shapes.
var top_y := INF
var bottom_y := -INF
var exclude_rids: Array[RID] = []
for key: String in _ragdoll_bodies:
var body := _ragdoll_bodies[key] as RigidBody2D
if body == null or not is_instance_valid(body):
continue
var half := float(body.get_meta("half_height", 0.0))
var radius := float(body.get_meta("radius", 0.0))
var extent := half + radius
top_y = minf(top_y, body.global_position.y - extent)
bottom_y = maxf(bottom_y, body.global_position.y + extent)
exclude_rids.append(body.get_rid())
_captured_pose[key] = {
"pos": to_local(body.global_position),
"rot": body.global_rotation - global_rotation,
"half": half,
}
# Record the ragdoll torso's world center + probe the terrain surface under
# the fallen figure so recovery can re-anchor the root's feet onto wherever
# the ragdoll actually landed (flat ground, slopes and ramp edges included).
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
if torso != null and is_instance_valid(torso):
_captured_landing_center = torso.global_position
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)
# Retain the probe context so the ankle clamp in _snap_skeleton_to_pose()
# can re-probe the terrain at each ankle's X using the same extents and
# exclusion set (without re-running the capture loop).
_probe_top_y = top_y
_probe_bottom_y = bottom_y
_probe_exclude = exclude_rids
## Fans downward raycasts around `world_x` to find the terrain surface under the
## landed ragdoll. Returns the accepted hit whose y is closest to `bottom_y`, or
## `bottom_y` when no acceptable hit is found. `exclude` carries the ragdoll
## bodies' RIDs so the probe ignores its own collision shapes.
func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
var best_y := bottom_y
var best_dist := INF
var space_state := get_world_2d().direct_space_state
if space_state == null:
return best_y
for offset: float in GROUND_PROBE_X_OFFSETS:
var query := PhysicsRayQueryParameters2D.create(
Vector2(world_x + offset, top_y - GROUND_PROBE_START_OFFSET),
Vector2(world_x + offset, bottom_y + GROUND_PROBE_END_OFFSET))
query.collision_mask = 1
query.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = exclude
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var hit_position: Vector2 = hit.get("position", Vector2.ZERO)
if hit_position.y < top_y - GROUND_PROBE_TOLERANCE or hit_position.y > bottom_y + GROUND_PROBE_TOLERANCE:
continue
var dist := absf(hit_position.y - bottom_y)
if dist < best_dist:
best_dist = dist
best_y = hit_position.y
return best_y
func _start_recovery() -> void:
if state != RigState.RAGDOLL:
return
_capture_ragdoll_pose()
_destroy_ragdoll()
_reanchor_root_to_landing()
state = RigState.RECOVERING
state_changed.emit(int(state))
_snap_skeleton_to_pose()
_play_stand_up()
## Re-anchors the rig root so the standing figure's feet sit on the ground at
## the ragdoll's landing X. The ragdoll bodies live under a world sibling (not
## the rig), so the rig root never moved while it fell; without this the
## stand-up tween would drag the figure back to the root's pre-ragdoll world
## position.
func _reanchor_root_to_landing() -> void:
if not _captured_pose.has("torso"):
return
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
var old_root := global_position
var new_root := feet_world + FOOT_OFFSET
global_position = new_root
# Re-base the captured (rig-local) pose onto the re-anchored root so the
# snap reproduces the ragdoll's world pose, not the pre-ragdoll one. The
# re-base must be mirror-aware: a world-space delta maps into rig-local
# space through the root basis's inverse (the LEFT X-mirror negates X;
# adding the raw shift would offset every position by twice the distance).
var shift := old_root - new_root
var local_shift := to_local(shift) - to_local(Vector2.ZERO)
for key: String in _captured_pose:
var entry: Dictionary = _captured_pose[key]
entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift
## 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
## Re-enables the Skeleton2D modification stack after RAGDOLL and forces it to
## resume solving. A disabled->enabled toggle alone can leave the stack's
## internal solve state stale (the classic Godot disable->enable quirk), so we
## explicitly re-setup the stack when it reports !is_setup, ensure Skeleton2D
## processes internally (required for the stack to execute), and re-assert the
## Torso marker's RemoteTransform2D update flags so the hip bone keeps
## following IK_Targets/Torso through the stand-up tween.
func _rearm_ik_stack() -> void:
if _skeleton == null or not is_instance_valid(_skeleton):
return
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
if not stack.get_is_setup():
stack.setup()
stack.enabled = true
_skeleton.set_process_internal(true)
# Torso marker -> Torso bone driver: ensure it forwards position/rotation.
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
if driver != null:
driver.update_position = true
driver.update_rotation = true
driver.update_scale = true
_recovery_dbg()
## Logs the IK stack + bone-following state during recovery (off by default).
func _recovery_dbg() -> void:
if not DEBUG_RECOVERY:
return
var stack: SkeletonModificationStack2D = null
var stack_enabled := false
var stack_setup := false
var internal := false
if _skeleton != null and is_instance_valid(_skeleton):
stack = _skeleton.modification_stack
stack_enabled = stack != null and stack.enabled
stack_setup = stack != null and stack.get_is_setup()
internal = _skeleton.is_processing_internal()
var torso_bone_pos := Vector2.ZERO
if _torso_bone != null and is_instance_valid(_torso_bone):
torso_bone_pos = _torso_bone.global_position
var torso_marker := _get_ik_marker("Torso")
var torso_marker_pos := torso_marker.global_position if torso_marker != null else Vector2.ZERO
var limb := _bend_joint_bones.get("LeftLeg") as Bone2D
var limb_pos := limb.global_position if limb != null and is_instance_valid(limb) else Vector2.ZERO
print("[recovery] stack.enabled=%s is_setup=%s internal=%s torso_bone=%s torso_marker=%s left_lower_leg=%s" % [
str(stack_enabled), str(stack_setup), str(internal),
str(torso_bone_pos), str(torso_marker_pos), str(limb_pos),
])
## Converts a captured relative angle (ragdoll body world rotation minus the
## rig root's global_rotation) into the skeleton's chain-space angle. Under the
## LEFT whole-rig X-mirror the root's global_rotation decomposes as (PI - phi),
## so captured relative angles come out negated: chain = -captured.
func _chain_angle_from_captured(captured_rel: float) -> float:
if facing_profile == FacingProfile.LEFT:
return -captured_rel
return captured_rel
## Wraps `value` to within +/-PI of `target` (shortest-rotation path) so a bone
## rotation tween never spins the long way around. The wrapped value renders
## identically to the raw one (2PI-periodic).
func _wrap_toward(value: float, target: float) -> float:
return target + wrapf(value - target, -PI, PI)
## Returns the tween target for a HINGE joint (elbows/knees only) so it bends
## the natural way, evaluated in the joint's RENDERED space (the value space is
## offset by `bone_angle_lower - bone_angle_upper` — e.g. -90deg for the legs).
## The shortest rendered path is used unless it would cross the +/-PI
## (fully-backward) boundary (opposite signs with |a| + |b| > PI) — a joint
## that fell bent backwards must straighten through 0 instead of folding
## further backwards. The returned target differs from `target` only by whole
## turns.
func _natural_joint_target(current: float, target: float, offset: float) -> float:
var a := wrapf(current + offset, -PI, PI)
var b := wrapf(target + offset, -PI, PI)
var d := wrapf(b - a, -PI, PI)
if a * b < 0.0 and absf(a) + absf(b) > PI:
d += TAU if d < 0.0 else -TAU
return current + d
## Enables/disables the 4 TwoBoneIK mods. Disabled during RECOVERING so the IK
## re-solve cannot overwrite the directly-driven limb bones; re-enabled when
## the stand-up completes (or on snap_to_standing).
func _set_limb_mods_enabled(enabled: bool) -> void:
for joint: String in BEND_JOINTS:
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod != null and is_instance_valid(mod):
mod.enabled = enabled
## Restores the head LookAt mod's enabled state to what it was captured as
## before ragdoll (fallback: disabled).
func _restore_head_lookat_state() -> void:
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
_head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false))
## Kinematic snap: writes the captured ragdoll pose onto the 6 IK-target
## markers (NOT the Torso Bone2D, which is slaved to its marker via
## RemoteTransform2D), drives the ARMS' limb Bone2D rotations directly from the
## captured body angles, and sets the head bone from the head body. The two arm
## TwoBoneIK mods and the head LookAt are disabled for the snap so the re-solve
## cannot overwrite the directly-driven bones. The two LEG TwoBoneIK mods are
## instead ENABLED so the legs solve toward their ankle markers — the ankles
## are clamped up onto the terrain surface (via a ground probe at each ankle's
## X) and then tween along the ground to the pre-ragdoll ankle positions while
## the torso marker rises, so the feet never sink below the ground. Each leg
## mod's bend side is matched to the captured knee side so the re-solve cannot
## flip a knee. The stand-up tween then rotates the markers + arms + head to
## the PRE-ragdoll captured state and re-enables the arm mods.
##
## 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. All derived joint points go through
## to_local(to_global(pos) + world_dir * half) so the LEFT whole-rig X-mirror
## and any rig root rotation are handled by the transform math itself. Rotations
## use entry-captured offsets instead of authored constants: the torso marker
## rotation θ_t is the captured spine chain minus `torso_chain_offset` (which
## absorbs the bone_angle / driver conventions), the limbs parent on θ_t, and
## the head bone is the captured head chain minus `head_chain_offset` (the head
## ragdoll body's baked entry chain) minus θ_t. Every snapped rotation is
## normalized into (-PI, PI] because Godot's Skeleton2D re-wraps bone rotations
## internally, which would otherwise corrupt the tween's captured START.
##
## This function ALSO computes and stores the per-bone tween targets in
## `_recovery_bones` (the torso marker, the 4 arm limb bones, and the head
## bone). Shoulders (upper arm bones) and the head take the shortest value
## path; elbows (lower arm bones) use `_natural_joint_target` so a joint that
## fell bent backwards straightens through 0 instead of folding further
## backwards through +/-PI. The legs are excluded from the bone tween — they
## are IK-driven toward their tweening ankle markers. `_play_stand_up()` then
## only starts the tween from those stored targets.
func _snap_skeleton_to_pose() -> void:
_recovery_bones.clear()
var pre_markers: Dictionary = _pre_ragdoll_state.get("markers", {})
var pre_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
var pre_head_rotation := float(_pre_ragdoll_state.get("head_rotation", 0.0))
var pre_torso_rot := 0.0
var torso_chain_offset := float(_pre_ragdoll_state.get("torso_chain_offset", 0.0))
var head_chain_offset := float(_pre_ragdoll_state.get("head_chain_offset", 0.0))
var theta_t := 0.0
var torso_chain := 0.0
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 torso_rel := float(torso_pose.get("rot", 0.0))
# World-space spine direction: the captured angle is relative to the
# root's global_rotation, so add it back to get the true world angle.
var spine_dir := Vector2.from_angle(torso_rel + global_rotation)
var half := float(torso_pose.get("half", 0.0))
# Hip = spine bottom end of the torso capsule, converted through
# to_local(to_global(...)) so the LEFT X-mirror and any rig root
# rotation are handled by the transform math itself.
var center_world := to_global(torso_pose.get("pos", Vector2.ZERO))
torso_marker.position = to_local(center_world - spine_dir * half)
torso_chain = _chain_angle_from_captured(torso_rel)
# The torso bone's node rotation = the captured spine chain minus
# the entry-captured offset (the bone_angle / driver conventions
# are baked into the offset, so no authored constants are needed).
var raw_rot := torso_chain - torso_chain_offset
pre_torso_rot = float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rot))
theta_t = _wrap_toward(raw_rot, pre_torso_rot)
theta_t = wrapf(theta_t, -PI, PI)
torso_marker.rotation = theta_t
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")
# Clamp the ankle markers up onto the terrain surface so the feet never
# sink below the ground during the stand-up (the leg IK follows them).
for leg_key: String in ["Left_Leg", "Right_Leg"]:
var leg_marker := _get_ik_marker(leg_key)
if leg_marker == null:
continue
var body_key: String = "left_lower_leg" if leg_key == "Left_Leg" else "right_lower_leg"
if _captured_pose.get(body_key, {}).is_empty():
continue
var ankle_world := to_global(leg_marker.position)
var ground_y := _probe_ground_y(ankle_world.x, _probe_top_y, _probe_bottom_y, _probe_exclude)
if ankle_world.y < ground_y:
ankle_world.y = ground_y
leg_marker.position = to_local(ankle_world)
# Drive the limb bones directly from the captured body angles: the ragdoll
# body angle IS the bone DIRECTION, while the Body/* visuals follow the bone
# NODE rotation, so the bone's own bone_angle is subtracted. The parent
# chain is the torso marker's snapped node rotation θ_t (the captured spine
# chain minus the entry-captured torso_chain_offset). The snapped rotations
# are normalized into (-PI, PI] (Godot re-wraps bone rotations internally);
# upper bones take the shortest path and lower bones use
# `_natural_joint_target` (through straight when bent backwards).
if not _captured_pose.get("torso", {}).is_empty():
for joint: String in LIMB_BONE_PATHS:
var pair: Dictionary = _limb_bones.get(joint, {})
var upper: Bone2D = pair.get("upper")
var lower: Bone2D = pair.get("lower")
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
continue
var body_keys: Array = LIMB_BODY_KEYS[joint]
var upper_pose: Dictionary = _captured_pose.get(body_keys[0], {})
var lower_pose: Dictionary = _captured_pose.get(body_keys[1], {})
if upper_pose.is_empty() or lower_pose.is_empty():
continue
var upper_chain := _chain_angle_from_captured(float(upper_pose.get("rot", 0.0)))
var lower_chain := _chain_angle_from_captured(float(lower_pose.get("rot", 0.0)))
var pre_pair: Dictionary = pre_limb_rotations.get(joint, {})
var parent_chain := theta_t
upper.rotation = _wrap_toward(
upper_chain - parent_chain - deg_to_rad(upper.bone_angle),
float(pre_pair.get("upper", upper.rotation)))
# The lower bone's node parent chain does NOT include the upper
# bone's bone_angle (bone_angle only appears in the ragdoll body
# direction), so it is added back before subtracting the lower's.
lower.rotation = _wrap_toward(
lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle),
float(pre_pair.get("lower", lower.rotation)))
# Normalize the snapped rotations into (-PI, PI]: Godot's Skeleton2D
# re-wraps bone rotations on its next internal process, which would
# otherwise change the tween's captured START and send it the long
# way around. With the start normalized, the wrap is a no-op.
upper.rotation = wrapf(upper.rotation, -PI, PI)
lower.rotation = wrapf(lower.rotation, -PI, PI)
if joint == "LeftLeg" or joint == "RightLeg":
# Legs are IK-driven during the stand-up (the ankles follow the
# ground-clamped markers then tween to the pre positions), so
# they get NO direct bone tween — but they ARE snapped to the
# captured pose above so the first recovery frame matches the
# ragdoll and the knee-side matching sees the true fallen pose.
continue
# Shoulders/hips (upper bones) take the shortest path; elbows/knees
# (lower bones) must pass through straight when the ragdoll bent
# them backwards.
var pre_u := float(pre_pair.get("upper", upper.rotation))
var pre_l := float(pre_pair.get("lower", lower.rotation))
var target_u := upper.rotation + wrapf(pre_u - upper.rotation, -PI, PI)
# The joint's rendered bend is offset by bone_angle_lower -
# bone_angle_upper (e.g. -90deg for the legs): evaluate the natural
# rule in that rendered space so the knee unfolds through straight
# instead of being sent the long way around. A "shortest" entry in
# RECOVERY_JOINT_PATH skips the natural rule entirely.
var target_l: float
var path_override := String(RECOVERY_JOINT_PATH.get(facing_profile, {}).get(joint, ""))
if path_override == "shortest":
target_l = lower.rotation + wrapf(pre_l - lower.rotation, -PI, PI)
else:
target_l = _natural_joint_target(lower.rotation, pre_l, deg_to_rad(lower.bone_angle - upper.bone_angle))
_recovery_bones.append({ "bone": upper, "target": target_u })
_recovery_bones.append({ "bone": lower, "target": target_l })
# The head ragdoll body is a "visual" body spawned with rotation 0, so its
# cloned visual carries the entry head chain as a baked offset — captured
# at entry and subtracted back here.
if not _captured_pose.get("head", {}).is_empty() and _head_bone != null and is_instance_valid(_head_bone):
var head_rel := float((_captured_pose["head"] as Dictionary).get("rot", 0.0))
var raw_head := _chain_angle_from_captured(head_rel) - head_chain_offset - theta_t
_head_bone.rotation = _wrap_toward(raw_head, pre_head_rotation)
_head_bone.rotation = wrapf(_head_bone.rotation, -PI, PI)
var target_h := _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)
_recovery_bones.append({ "bone": _head_bone, "target": target_h })
# Show the kinematic puppet first so it appears already in the captured
# pose, then re-arm the stack (torso driver) with the limb TwoBoneIK mods
# and the head LookAt disabled so nothing re-solves the snapped bones.
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
_rearm_ik_stack()
_set_limb_mods_enabled(false)
if _head_look_at != null and is_instance_valid(_head_look_at):
_head_look_at.enabled = false
# Enable the two leg TwoBoneIK mods so the legs solve toward the ankle
# markers (clamped on the ground) while the torso marker rises. Match each
# leg's bend side to the captured pose so the re-solve cannot flip a knee.
for leg_joint: String in ["LeftLeg", "RightLeg"]:
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK
if mod == null or not is_instance_valid(mod):
continue
var leg_sides: Dictionary = _pre_ragdoll_state.get("leg_sides", {})
var entry: Dictionary = leg_sides.get(leg_joint, {})
var force_profile: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(facing_profile, {})
if force_profile.has(leg_joint):
# Manual override from the editable RECOVERY_LEG_SIDE_FORCE table.
mod.flip_bend_direction = bool(force_profile[leg_joint])
elif not entry.is_empty():
# Derive the captured knee side from the RAGDOLL capture in world
# space. The bone nodes are NOT reliable here: the torso marker's
# driver has not pushed the re-anchored hip yet, so bone globals
# would mix the stale entry hip with the snapped limb rotations
# and produce a garbage side (both knees flipping at the first
# solve).
var up_key: String = "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg"
var low_key: String = "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg"
var up_pose: Dictionary = _captured_pose.get(up_key, {})
var low_pose: Dictionary = _captured_pose.get(low_key, {})
if not up_pose.is_empty() and not low_pose.is_empty():
var dir_u := Vector2.from_angle(float(up_pose.get("rot", 0.0)) + global_rotation)
var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + global_rotation)
var half_u := float(up_pose.get("half", 0.0))
var half_l := float(low_pose.get("half", 0.0))
var center_u := to_global(up_pose.get("pos", Vector2.ZERO))
var hip := center_u - dir_u * half_u
var knee := center_u + dir_u * half_u
var ankle := to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * half_l
var side := (ankle - hip).cross(knee - hip)
var captured_side := 1.0 if side >= 0.0 else -1.0
var entry_side := float(entry.get("side", captured_side))
var entry_flag := bool(entry.get("flag", false))
mod.flip_bend_direction = entry_flag if captured_side == entry_side else not entry_flag
mod.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, in WORLD
# space (the captured angle is root-relative), converted back to local via
# to_local so the LEFT X-mirror and rig root rotation are handled exactly.
var dir := Vector2.from_angle(float(pose.get("rot", 0.0)) + global_rotation)
var half := float(pose.get("half", 0.0))
var center_world := to_global(pose.get("pos", Vector2.ZERO))
marker.position = to_local(center_world + 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: starts the tween from the captured ragdoll pose to the PRE-ragdoll
## captured state (sine ease-in-out, same duration). 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/bones.
##
## The per-bone tween targets (minimal world-direction sweeps) are computed and
## stored in `_recovery_bones` by `_snap_skeleton_to_pose()`; this function only
## starts the marker tween and appends one bone-rotation tween per entry.
func _play_stand_up() -> void:
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
if _stand_up_tween != null:
for entry: Dictionary in _recovery_bones:
var bone: Bone2D = entry.get("bone")
if bone == null or not is_instance_valid(bone):
continue
_stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION)
_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:
# Restore the pre-ragdoll facing profile + per-joint bend flags so the
# re-enabled IK holds the exact pre-ragdoll pose.
set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile))))
var bends: Dictionary = _pre_ragdoll_state.get("bends", {})
for joint: String in BEND_JOINTS:
if bends.has(joint):
set_joint_bend_flipped(joint, bool(bends[joint]))
_set_limb_mods_enabled(true)
_restore_head_lookat_state()
_recovery_bones.clear()
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)
body.set_meta("radius", float(entry["radius"]))
if not _clone_part_visuals(body, str(entry.get("visual_path", ""))):
_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)
body.set_meta("radius", float(entry["radius"]))
if not _clone_part_visuals(body, str(entry.get("visual_path", ""))):
_add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR)
_ragdoll_root.add_child(body)
_ragdoll_bodies[key] = body
## Clones the part node's mounted shape children (Line2D open outlines /
## Polygon2D closed fills, added by StkRigAdapter as children of the Body/*
## part node) into the ragdoll body's local frame, so the ragdoll shows the
## stickman's own geometry instead of the gray fallback capsule/circle. Returns
## true if at least one shape was cloned.
func _clone_part_visuals(body: RigidBody2D, visual_path: String) -> bool:
if visual_path.is_empty():
return false
var source := get_node_or_null(NodePath(visual_path)) as Node2D
if source == null or not is_instance_valid(source):
return false
var cloned := 0
for child: Node in source.get_children():
if child is Line2D:
var src := child as Line2D
var copy := Line2D.new()
var pts := PackedVector2Array()
for p: Vector2 in src.points:
pts.append(body.to_local(src.to_global(p)))
copy.points = pts
copy.width = src.width
copy.default_color = src.default_color
if src.gradient != null:
copy.gradient = src.gradient
copy.begin_cap_mode = src.begin_cap_mode
copy.end_cap_mode = src.end_cap_mode
copy.joint_mode = src.joint_mode
copy.closed = src.closed
copy.antialiased = src.antialiased
copy.position = Vector2.ZERO
copy.name = "Visual" + str(body.get_child_count())
body.add_child(copy)
cloned += 1
elif child is Polygon2D:
var src := child as Polygon2D
var copy := Polygon2D.new()
var pts := PackedVector2Array()
for p: Vector2 in src.polygon:
pts.append(body.to_local(src.to_global(p)))
copy.polygon = pts
copy.color = src.color
copy.antialiased = src.antialiased
copy.position = Vector2.ZERO
copy.name = "Visual" + str(body.get_child_count())
body.add_child(copy)
cloned += 1
return cloned > 0
## 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"], str(entry["child"]))
## Applies angular limits to a ragdoll PinJoint2D. The elbow/knee band comes
## from the editable RAGDOLL_JOINT_BAND_LIMITS table (per profile, per joint,
## in degrees); the static `limit` string is only a fallback for joints
## missing from the table. `shoulder_hip` is ±160°; other limits leave the
## joint free.
func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String, child_key: String = "") -> void:
match limit:
"elbow_knee", "elbow_knee_ccw":
var band: Dictionary = RAGDOLL_JOINT_BAND_LIMITS.get(facing_profile, {}).get(child_key, {})
if band.is_empty():
if limit == "elbow_knee":
band = { "lower": -5.0, "upper": 150.0 }
else:
band = { "lower": -150.0, "upper": 5.0 }
pin.angular_limit_enabled = true
pin.angular_limit_lower = deg_to_rad(float(band.get("lower", -5.0)))
pin.angular_limit_upper = deg_to_rad(float(band.get("upper", 150.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
if dx < -0.5:
set_facing_profile(FacingProfile.LEFT)
elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT)
# Always play the canonical walk_right clip. For LEFT the rig root is
# X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored;
# walk_left is no longer used at runtime.
var 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