class_name StickmanRig extends Node2D ## StickmanRig - Runtime owner of facing direction and per-joint bone bend for ## scenes/stickman_rig.tscn (Phase 9 / Task 4). ## ## Attached to the `Master` root node of scenes/stickman_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", ], } const ANIMATION_PLAYER_PATH := "AnimationPlayer" ## Upper + lower Bone2D paths (Skeleton2D-relative), retained for mirrored ## idle-pose preservation and legacy test access. 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"], } ## Debug gate for the Phase 3a walk/runner trace. Ship OFF. const DEBUG_WALK := false const DEBUG_TERRAIN := false # --------------------------------------------------------------------------- # 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() signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String) # --------------------------------------------------------------------------- # 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 _anim_player: AnimationPlayer = null var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } } # --------------------------------------------------------------------------- # Director / navigation state (Phase 3a) # --------------------------------------------------------------------------- var _pending_action_queue: Array[Dictionary] = [] var action_queue: Array[Dictionary]: get: return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue set(value): if _action_runner_component != null: _action_runner_component.set_authored_queue(value) else: _pending_action_queue = value.duplicate(true) ## Original .stk asset path, assigned by StageSpawner for scene persistence. var source_stk_path: String = "" ## Authored nonvisual subsystem children. They establish explicit ownership ## boundaries while this facade preserves the long-standing public API. var _locomotion_component: StickmanLocomotion = null var _ragdoll_component: StickmanRagdoll = null var _action_runner_component: StickmanActionRunner = null var _prop_interactor_component: Node = null var _simulation_paused: bool = false var _phase12_paused_animation: bool = false var _phase12_paused_animation_name: StringName = &"" var _phase12_paused_animation_position: float = 0.0 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion _ragdoll_component = get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll _action_runner_component = get_node_or_null(NodePath("RigComponents/ActionRunner")) as StickmanActionRunner _prop_interactor_component = get_node_or_null(NodePath("RigComponents/PropInteractor")) if _locomotion_component == null: push_warning("StickmanRig: missing RigComponents/Locomotion; locomotion boundary unavailable.") if _ragdoll_component == null: push_warning("StickmanRig: missing RigComponents/Ragdoll; ragdoll boundary unavailable.") if _action_runner_component == null: push_warning("StickmanRig: missing RigComponents/ActionRunner; action boundary unavailable.") if _prop_interactor_component == null: push_warning("StickmanRig: missing RigComponents/PropInteractor; prop interactions unavailable.") if _locomotion_component != null: _locomotion_component.configure(self) _locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target)) if _ragdoll_component != null: _ragdoll_component.configure(self, _locomotion_component) _ragdoll_component.recovery_requested.connect(func() -> void: request_recovery()) _ragdoll_component.state_transition_requested.connect(_set_state) if _prop_interactor_component != null: _prop_interactor_component.call("configure", self, _locomotion_component, Callable()) _prop_interactor_component.connect("prop_event", _on_prop_interaction_event) if _action_runner_component != null: _action_runner_component.configure(self, _locomotion_component, _prop_interactor_component) _action_runner_component.action_queue = _pending_action_queue _action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index)) _action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index)) _action_runner_component.queue_finished.connect(func() -> void: queue_finished.emit()) _action_runner_component.queue_changed.connect(func() -> void: queue_changed.emit()) _action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit()) _action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled)) _action_runner_component.recovery_requested.connect(func() -> void: request_recovery()) _action_runner_component.active_action_cancelled.connect(_on_active_action_cancelled) # 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() if _locomotion_component != null: _locomotion_component.setup_collision_bodies() # 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() if _ragdoll_component != null: _ragdoll_component.initialize(_skeleton) func _physics_process(delta: float) -> void: if _simulation_paused: return if _ragdoll_component != null: _ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover) if _locomotion_component != null: _locomotion_component.tick_movement(delta, int(state)) if _prop_interactor_component != null: _prop_interactor_component.call("tick", delta) if _action_runner_component != null: _action_runner_component.tick(delta, int(state), is_ragdoll_at_rest()) _pin_mirrored_head_rotation() if _locomotion_component != null: _locomotion_component.tick_post_skeleton(delta, int(state)) func get_locomotion() -> StickmanLocomotion: return _locomotion_component func get_collision_bodies() -> Dictionary: return _locomotion_component.collision_bodies if _locomotion_component != null else {} func get_collision_body_rids() -> Array[RID]: var result: Array[RID] = [] for proxy: AnimatableBody2D in get_collision_bodies().values(): if proxy != null and is_instance_valid(proxy): var rid: RID = proxy.get_rid() if rid.is_valid(): result.append(rid) return result ## Narrow shared skeleton service used by locomotion and ragdoll components. func _get_ik_marker(marker_name: String) -> Marker2D: var path := String(StickmanRagdoll.IK_TARGET_PATHS.get(marker_name, "")) if path.is_empty(): return null return get_node_or_null(NodePath(path)) as Marker2D ## 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.0 ## Public terrain facade. Runtime terrain state and implementation live in RigComponents/Locomotion. func begin_terrain_fall() -> void: if _locomotion_component != null: _locomotion_component.begin_terrain_fall() func snap_to_terrain() -> void: begin_terrain_fall() func set_terrain_following_enabled(enabled: bool) -> void: if _locomotion_component != null: _locomotion_component.set_terrain_following_enabled(enabled) func _is_action_queue_blocked() -> bool: return _locomotion_component != null and _locomotion_component.is_action_blocked() # --------------------------------------------------------------------------- # 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 speech_head_geometry() -> Dictionary: var head_visual: Node2D = _body_container.get_node_or_null(NodePath("Head")) as Node2D if _body_container != null and is_instance_valid(_body_container) else null if state == RigState.RAGDOLL and _ragdoll_component != null and is_instance_valid(_ragdoll_component): var body_nodes: Variant = _ragdoll_component.call("get_body_nodes") if body_nodes is Array: for raw_body: Variant in body_nodes as Array: if not raw_body is RigidBody2D: continue var body: RigidBody2D = raw_body as RigidBody2D if String(body.get_meta("part_name", "")) == "head": var body_radius: float = float(body.get_meta("radius", 100.0)) var body_scale: Vector2 = body.global_scale.abs() return {"center": to_local(body.global_position), "radius": body_radius * maxf(body_scale.x, body_scale.y)} if head_visual != null and is_instance_valid(head_visual): var raw_radius: Variant = head_visual.get("radius") var radius: float = float(raw_radius) if raw_radius is int or raw_radius is float else 100.0 var head_scale: Vector2 = head_visual.global_scale.abs() return {"center": to_local(head_visual.global_position), "radius": radius * maxf(head_scale.x, head_scale.y)} if _head_bone != null and is_instance_valid(_head_bone): return {"center": to_local(_head_bone.global_position), "radius": 100.0 * maxf(absf(_head_bone.global_scale.x), absf(_head_bone.global_scale.y))} return {"center": Vector2(0.0, -310.0), "radius": 100.0} 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_state(next_state: int) -> void: if next_state < RigState.ANIMATED or next_state > RigState.RECOVERING: push_warning("StickmanRig: invalid state transition target %d." % next_state) return if int(state) == next_state: return state = next_state state_changed.emit(int(state)) func set_ragdoll(enabled: bool) -> void: if enabled: if _prop_interactor_component != null: _prop_interactor_component.call("disarm_for_ragdoll") if _locomotion_component != null: _locomotion_component.reset_for_ragdoll_handoff() match state: RigState.RAGDOLL: return RigState.RECOVERING: if _ragdoll_component != null: _ragdoll_component.cancel_recovery() if _ragdoll_component.enter_ragdoll(): _set_state(RigState.RAGDOLL) _: if _ragdoll_component != null and _ragdoll_component.enter_ragdoll(): _set_state(RigState.RAGDOLL) else: if state == RigState.RAGDOLL: request_recovery() # else ANIMATED / RECOVERING: no-op func toggle_ragdoll() -> void: set_ragdoll(not is_in_ragdoll()) func set_prop_resolver(resolver: Callable) -> void: if _prop_interactor_component != null: _prop_interactor_component.call("configure", self, _locomotion_component, resolver) func start_prop_action(action: Dictionary) -> bool: return bool(_prop_interactor_component.call("start_action", action)) if _prop_interactor_component != null else false func cancel_prop_action() -> void: if _prop_interactor_component != null: _prop_interactor_component.call("cancel_action") func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary: if _prop_interactor_component == null: return {} return _prop_interactor_component.call("preview_throw", target, queue_prefix) func reset_prop_interactions_for_scene_exit() -> void: if _prop_interactor_component != null: _prop_interactor_component.call("reset_for_scene_exit") func _on_prop_interaction_event(event_type: String, prop: PropBlock, reason: String) -> void: prop_interaction_event.emit(event_type, prop, reason) ## Public stand-up request. No-op unless the rig is in RAGDOLL. func request_recovery() -> void: if state == RigState.RAGDOLL and _ragdoll_component != null: _ragdoll_component.request_recovery() ## Instantly restores the rig's factory-captured spawn/ghost pose — no stand-up ## tween. Used by the sandbox stage so a stickman reappears in its authored ## starting pose when leaving PLAY (instead of using the generic fallback pose). func snap_to_standing() -> void: if _ragdoll_component != null: _ragdoll_component.snap_to_standing(int(state)) _simulation_paused = false _phase12_paused_animation = false if _action_runner_component != null: _action_runner_component.clear_pause_state() if _ragdoll_component != null: _ragdoll_component.clear_pause_snapshot() ## Restores the spawn-marker pose through the ragdoll component. Kept on the ## facade for locomotion and legacy callers. func _restore_standing_markers(use_spawn_pose: bool = true) -> void: if _ragdoll_component != null: _ragdoll_component._restore_standing_markers(use_spawn_pose) ## Captures adapted skeleton endpoints as the initial IK marker pose. Called by ## StickmanFactory before the instance enters the tree; safe for factory bypasses. func initialize_spawn_pose_from_skeleton() -> void: var component := get_node_or_null(NodePath("RigComponents/Ragdoll")) as StickmanRagdoll if component == null: push_warning("StickmanRig: missing RigComponents/Ragdoll for spawn-pose initialization.") return if _ragdoll_component == null: _ragdoll_component = component if _locomotion_component == null: _locomotion_component = get_node_or_null(NodePath("RigComponents/Locomotion")) as StickmanLocomotion component.configure(self, _locomotion_component) component.initialize_spawn_pose_from_skeleton() ## 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 if _ragdoll_component != null: _ragdoll_component.apply_velocity_boost(velocity) # --------------------------------------------------------------------------- # 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 } func set_collision_bodies_enabled(enabled: bool) -> void: if _locomotion_component != null: _locomotion_component.set_collision_bodies_enabled(enabled) func apply_collision_reaction( part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF ) -> void: if _locomotion_component != null: _locomotion_component.apply_collision_reaction(part_name, impact_direction, impact_speed, impact_position) ## 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) _sync_speech_bubble_mirror() _apply_head_lookat_mirror_mode() _apply_idle_limb_solver_mode() facing_profile_changed.emit(int(facing_profile)) ## Godot's TwoBoneIK solver is not mirror-invariant under a negative X scale. ## While a LEFT-facing walk is active the animation must keep driving IK, but ## once the walk stops an enabled solver can settle into a pose that never ## existed in the clip. Pin the limbs to the exact frame captured at stop. func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void: var pin_mirrored_idle := facing_profile == FacingProfile.LEFT \ and state == RigState.ANIMATED and not is_walking() var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations() _set_limb_mods_enabled(not pin_mirrored_idle) # Recovery supplies the exact pre-ragdoll rotations. Write them immediately # even for profiles that keep IK enabled so the completion frame cannot show # the recovery-side knee solution while the solver waits for its next tick. if not pin_mirrored_idle and rotations.is_empty(): return for joint: String in source: var bones: Dictionary = _limb_bones.get(joint, {}) var saved: Dictionary = source[joint] var upper := bones.get("upper") as Bone2D var lower := bones.get("lower") as Bone2D if upper != null and is_instance_valid(upper): upper.rotation = float(saved.get("upper", upper.rotation)) if lower != null and is_instance_valid(lower): lower.rotation = float(saved.get("lower", lower.rotation)) func _capture_limb_rotations() -> Dictionary: var rotations: Dictionary = {} for joint: String in _limb_bones: var bones: Dictionary = _limb_bones[joint] var upper := bones.get("upper") as Bone2D var lower := bones.get("lower") as Bone2D if upper != null and lower != null and is_instance_valid(upper) and is_instance_valid(lower): rotations[joint] = { "upper": upper.rotation, "lower": lower.rotation, } return rotations ## 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 ## authored canonical local rotation (0), 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 ANIMATED; recovery disables ## LookAt separately while its stand-up tween drives the head bone. 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 = not mirrored if mirrored and _head_bone != null and is_instance_valid(_head_bone): _head_bone.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) ## 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 # --------------------------------------------------------------------------- # 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 _locomotion_component != null: _locomotion_component.walk_to(target, speed) func is_walking() -> bool: return _locomotion_component.is_walking() if _locomotion_component != null else false # --------------------------------------------------------------------------- # 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 _action_runner_component != null: _action_runner_component.speak(text, duration) func speak_action(action: Dictionary) -> void: if _action_runner_component != null: _action_runner_component.speak_action(action) func set_speech_preview_suppressed(value: bool) -> void: if _action_runner_component != null: _action_runner_component.set_speech_preview_suppressed(value) func _speak_overlay(text: String, duration: float) -> void: if _action_runner_component != null: _action_runner_component.speak_overlay(text, duration) func _hide_speech() -> void: if _action_runner_component != null: _action_runner_component.hide_speech() func _sync_speech_bubble_mirror() -> void: if _action_runner_component != null: _action_runner_component.sync_facing(facing_profile) # --------------------------------------------------------------------------- # Action queue (Phase 3a) # --------------------------------------------------------------------------- func queue_action(action: Dictionary) -> void: if _action_runner_component != null: insert_action(queue_size(), action) else: _pending_action_queue.append(action.duplicate(true)) func clear_queue() -> void: if _action_runner_component != null: _action_runner_component.clear_queue() else: _pending_action_queue.clear() func get_queue() -> Array[Dictionary]: return _action_runner_component.get_queue() if _action_runner_component != null else _pending_action_queue.duplicate(true) func remove_action(index: int) -> void: if _action_runner_component != null: _action_runner_component.remove_action(index) elif index >= 0 and index < _pending_action_queue.size(): _pending_action_queue.remove_at(index) func insert_action(index: int, action: Dictionary) -> void: if _action_runner_component != null: var normalized: Dictionary = _with_recover_barrier(action) if is_ragdolled() and String(normalized.get("type", "")) == "walk_to": var queue: Array[Dictionary] = get_queue() var at: int = clampi(index, 0, queue.size()) var has_recover_barrier: bool = false if at > 0 and String(queue[at - 1].get("type", "")) == "recover": var previous_status: int = get_action_status(action_id_at(at - 1)) has_recover_barrier = previous_status == StickmanActionRunner.QueueStatus.PENDING or previous_status == StickmanActionRunner.QueueStatus.ACTIVE if not has_recover_barrier: _action_runner_component.insert_action(at, {"type": "recover"}) at += 1 _action_runner_component.insert_action(at, normalized) else: _action_runner_component.insert_action(index, normalized) else: _pending_action_queue.insert(clampi(index, 0, _pending_action_queue.size()), action.duplicate(true)) func replace_action(index: int, action: Dictionary) -> bool: if _action_runner_component == null: return false if is_ragdolled() and String(action.get("type", "")) == "walk_to": var queue: Array[Dictionary] = get_queue() var has_recover_barrier: bool = false if index > 0 and index < queue.size() and String(queue[index - 1].get("type", "")) == "recover": var previous_status: int = get_action_status(action_id_at(index - 1)) has_recover_barrier = previous_status == StickmanActionRunner.QueueStatus.PENDING or previous_status == StickmanActionRunner.QueueStatus.ACTIVE if has_recover_barrier: return _action_runner_component.replace_action(index, action.duplicate(true)) var replaced: bool = _action_runner_component.replace_action(index, {"type": "recover"}) if not replaced: return false _action_runner_component.insert_action(index + 1, action.duplicate(true)) return true return _action_runner_component.replace_action(index, _with_recover_barrier(action)) func move_action(from_index: int, to_index: int) -> bool: return _action_runner_component.move_action(from_index, to_index) if _action_runner_component != null else false func get_queue_view() -> Array[Dictionary]: return _action_runner_component.get_queue_view() if _action_runner_component != null else [] func action_id_at(index: int) -> int: return _action_runner_component.action_id_at(index) if _action_runner_component != null else -1 func index_for_action_id(action_id: int) -> int: return _action_runner_component.index_for_action_id(action_id) if _action_runner_component != null else -1 func get_action_status(action_id: int) -> int: return _action_runner_component.get_action_status(action_id) if _action_runner_component != null else StickmanActionRunner.QueueStatus.NOT_STARTED func pause_simulation() -> void: if _simulation_paused: return _simulation_paused = true if _action_runner_component != null: _action_runner_component.pause_simulation() if _ragdoll_component != null: _ragdoll_component.pause_simulation() if _anim_player != null and is_instance_valid(_anim_player) and _anim_player.is_playing(): _phase12_paused_animation_name = _anim_player.current_animation _phase12_paused_animation_position = _anim_player.get_current_animation_position() _phase12_paused_animation = true _anim_player.pause() func resume_simulation() -> void: if not _simulation_paused: return if _ragdoll_component != null: _ragdoll_component.resume_simulation() if _action_runner_component != null: _action_runner_component.resume_simulation() _simulation_paused = false if _phase12_paused_animation and _anim_player != null and is_instance_valid(_anim_player): if _anim_player.current_animation == _phase12_paused_animation_name: _anim_player.play(_phase12_paused_animation_name) _anim_player.seek(_phase12_paused_animation_position, true) _phase12_paused_animation = false _phase12_paused_animation_name = &"" _phase12_paused_animation_position = 0.0 if _locomotion_component != null: _locomotion_component.ensure_grounded_walk_animation() func reset_simulation_pause() -> void: if _ragdoll_component != null: _ragdoll_component.clear_pause_snapshot() if _action_runner_component != null: _action_runner_component.clear_pause_state() _simulation_paused = false _phase12_paused_animation = false _phase12_paused_animation_name = &"" _phase12_paused_animation_position = 0.0 func get_director_anchor() -> Dictionary: var bounds: Rect2 = _ragdoll_component.get_world_body_bounds() if _ragdoll_component != null and state == RigState.RAGDOLL else Rect2() var feet: Vector2 = global_position - StickmanLocomotion.FOOT_OFFSET var airborne: bool = _locomotion_component != null and _locomotion_component.terrain_motion == StickmanLocomotion.TerrainMotion.AIRBORNE if state == RigState.RAGDOLL and not bounds.size.is_zero_approx(): feet = Vector2(bounds.get_center().x, bounds.end.y) var probe_origin: Vector2 = Vector2(bounds.get_center().x, feet.y) if state == RigState.RAGDOLL and not bounds.size.is_zero_approx() else feet var query := PhysicsRayQueryParameters2D.create(probe_origin + Vector2.UP * 40.0, probe_origin + Vector2.DOWN * 1800.0) query.collision_mask = 0x7FFFFFFF query.collide_with_areas = false var excluded: Array[RID] = [] _collect_nonterrain_rids(get_parent(), excluded) query.exclude = excluded var hit: Dictionary = get_world_2d().direct_space_state.intersect_ray(query) var has_ground: bool = not hit.is_empty() return {"feet": feet, "ground": hit.get("position", feet), "has_ground": has_ground, "airborne": airborne or state == RigState.RAGDOLL} func is_ragdolled() -> bool: return state == RigState.RAGDOLL func _on_active_action_cancelled(action: Dictionary) -> void: if String(action.get("type", "")) == "recover" and state == RigState.RECOVERING and _ragdoll_component != null: _ragdoll_component.interrupt_recovery_to_ragdoll() func _with_recover_barrier(action: Dictionary) -> Dictionary: return action.duplicate(true) func _collect_nonterrain_rids(node: Node, out: Array[RID]) -> void: if node == null: return if node is TerrainBlock: return if node is CollisionObject2D: var rid: RID = (node as CollisionObject2D).get_rid() if rid.is_valid() and not out.has(rid): out.append(rid) for child: Node in node.get_children(): _collect_nonterrain_rids(child, out) func queue_size() -> int: return _action_runner_component.queue_size() if _action_runner_component != null else _pending_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 _action_runner_component != null: _action_runner_component.enqueue_reactive(actions) ## 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 _action_runner_component != null: _action_runner_component.clear_reactive_actions() # --------------------------------------------------------------------------- # Runner state machine (Phase 3a) # --------------------------------------------------------------------------- ## Start running the queue. Empty queue emits queue_finished immediately. func start_queue() -> void: if _action_runner_component != null: _action_runner_component.start_queue() ## Abort the current action and return to IDLE. Does not emit queue_finished. func stop_queue() -> void: if _action_runner_component != null: _action_runner_component.stop_queue() func is_queue_running() -> bool: return _action_runner_component != null and _action_runner_component.is_queue_running() func is_ragdoll_at_rest() -> bool: return _ragdoll_component.is_at_rest() if _ragdoll_component != null else false