Files
stickman/scripts/stickman_rig.gd
T
ryan ef30931b20 Add sandbox stage builder scripts and functionality
- Introduced StageGizmos for hover highlighting, selection outlines, and rotation handles in the sandbox stage builder.
- Added StageGrid for an optional world-space grid overlay that adjusts with camera panning and zooming.
- Implemented StageSelection for geometric hit-testing and selection management of nodes in the sandbox.
- Created StageSpawner as a registry-driven factory for spawning terrain, props, and stickmen, allowing for dynamic template management.
- Each script includes necessary constants, state management, and public API methods for interaction.
2026-08-28 21:53:55 -04:00

914 lines
38 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class_name StickmanRig
extends Node2D
## StickmanRig - Runtime owner of facing direction and per-joint bone bend for
## master_rig.tscn (Phase 9 / Task 4).
##
## Attached to the `Master` root node of master_rig.tscn. Owns the facing
## preset, the four per-joint TwoBoneIK "Flip Bend Direction" flags, and the
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
## reordering run only at runtime (_ready + setters on a live instance).
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
## Facing profiles. Values are used directly as facing-menu item ids in the
## harness (0/1/2), so they must stay stable.
enum FacingProfile { LEFT, RIGHT, FORWARD }
## Per-joint bend direction. INVERTED == flip_bend_direction = true.
enum BendDirection { NORMAL, INVERTED }
## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a
## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll);
## RECOVERING snaps the skeleton back to the captured rest pose and tweens the
## IK targets to the standing pose before returning to ANIMATED.
enum RigState { ANIMATED, RAGDOLL, RECOVERING }
# ---------------------------------------------------------------------------
# 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",
}
## 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
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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()
_prev_global_pos = global_position
_prev_global_rot = global_rotation
func _physics_process(delta: float) -> void:
_track_momentum(delta)
_update_rest_detection(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
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()
# Set the IK targets directly to the standing pose (no tween).
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)
# 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))
## 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
# 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()