Files
stickman/scripts/stickman_rig.gd
T
ryan db4ff8d939 Implement Phase 8: Rig Relocation and Component Modularization
- Moved the rig scene from `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn`, preserving its UID.
- Split the `StickmanRig` controller into three components: `StickmanLocomotion`, `StickmanRagdoll`, and `StickmanActionRunner`.
- Updated the architecture to ensure `StickmanRig` acts as the public facade for the new components.
- Created a new test suite `test_phase8_rig_modularization.gd` to verify the integrity of the relocated rig and its components.
- Added checks for scene loading, component resolution, action queue delegation, and state authority.
2026-09-18 14:11:53 -04:00

815 lines
33 KiB
GDScript

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()
# ---------------------------------------------------------------------------
# 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.action_queue = value
else:
_pending_action_queue = value
## 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
# ---------------------------------------------------------------------------
# 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
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 _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 _action_runner_component != null:
_action_runner_component.configure(self, _locomotion_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())
# 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 _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 _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 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 _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())
## 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 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 _ragdoll_component != null:
_ragdoll_component.snap_to_standing(int(state))
## 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)
if not pin_mirrored_idle:
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 live (ANIMATED/RECOVERING)
## because re-enabling the IK stack during recovery would otherwise let the
## LookAt flip the bone again.
func _apply_head_lookat_mirror_mode() -> void:
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
var mirrored := facing_profile == FacingProfile.LEFT
_head_look_at.enabled = false#not mirrored
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
_head_bone.rotation = 0.0
elif not mirrored:
_head_bone.rotation = 0
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
## setter only stored the backing value during instantiation).
func _set_joint_bend_inverted(joint: String, inverted: bool) -> void:
if not _nodes_ready:
return
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod != null:
mod.flip_bend_direction = inverted
bend_flag_changed.emit(joint, inverted)
## Task 2 algorithm: walk the profile's ordered part names back-to-front and
## move_child(part, count - 1) each existing part; unknown/extra children stay
## at the back; missing parts skipped silently.
func _apply_body_z_order() -> void:
if _body_container == null or not is_instance_valid(_body_container):
return
var order: Array = Z_ORDER_BY_PROFILE.get(facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD])
for part_name: String in order:
var part := _body_container.get_node_or_null(NodePath(part_name))
if part != null:
_body_container.move_child(part, _body_container.get_child_count() - 1)
## 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_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: _action_runner_component.queue_action(action)
func clear_queue() -> void:
if _action_runner_component != null: _action_runner_component.clear_queue()
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)
func insert_action(index: int, action: Dictionary) -> void:
if _action_runner_component != null: _action_runner_component.insert_action(index, action)
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