Files
stickman/scripts/stickman_rig.gd
T
ryan e3df1cc5c0 feat: Implement kinematic-to-ragdoll transition system
- Added KINEMATIC_BLENDING_AND_RECOVERY.md to outline features for smooth transitions between kinematic and ragdoll states, including visual and physical blending, and ragdoll recovery.
- Introduced KINEMATIC_TO_RAGDOLL.md detailing the objectives, scope, and core architecture for transitioning the stickman from kinematic to ragdoll mode.
- Created KINEMATIC_TO_RAGDOLL_SPEC.md as an implementation specification, verifying codebase facts and correcting the initial plan based on Godot 4.4 source.
- Enhanced StickmanRig with state management for animated and ragdoll modes, including momentum preservation and ragdoll construction.
- Updated physics_test_harness to support toggling between kinematic and ragdoll states with user input.
2026-08-27 00:05:17 -04:00

629 lines
26 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).
enum RigState { ANIMATED, RAGDOLL }
# ---------------------------------------------------------------------------
# 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
## 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)
# ---------------------------------------------------------------------------
# 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 _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
# ---------------------------------------------------------------------------
# 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)
_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)
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
# ---------------------------------------------------------------------------
# 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 and not is_in_ragdoll():
_enter_ragdoll()
elif not enabled and is_in_ragdoll():
_exit_ragdoll()
func toggle_ragdoll() -> void:
set_ragdoll(not is_in_ragdoll())
## 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
# Freeze the kinematic puppet: disable IK, stop animation, hide visuals.
if _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = false
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
_body_container.visible = false
_build_ragdoll()
state = RigState.RAGDOLL
state_changed.emit(int(state))
func _exit_ragdoll() -> void:
_destroy_ragdoll()
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
if _skeleton != null and is_instance_valid(_skeleton):
if _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop()
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
_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()
_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 = 0.0
_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):
_ragdoll_root.queue_free()
_ragdoll_root = null
_ragdoll_bodies.clear()