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