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