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.
This commit is contained in:
2026-08-27 00:05:17 -04:00
parent 6edf3e53e3
commit e3df1cc5c0
12 changed files with 1325 additions and 57 deletions
+106 -17
View File
@@ -4,8 +4,9 @@ extends Node2D
##
## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils
## factory, spawns a master_rig.tscn instance standing on the flat ground, and
## provides camera zoom/pan input. NOT wired into the editor — run standalone
## via F6 on res://scenes/physics_test_harness.tscn.
## provides camera zoom/pan input plus a top-bar UI (spawn buttons, a
## Stickman/Ragdoll mode toggle, and a "Knock Up" force button). NOT wired into
## the editor — run standalone via F6 on res://scenes/physics_test_harness.tscn.
# ---------------------------------------------------------------------------
# Constants
@@ -32,6 +33,9 @@ const RIG_SPAWN_POSITION := Vector2(0.0, -385.0)
## Spawn point for dynamic props: above the angled ramp so they tumble down.
const PROP_SPAWN_POSITION := Vector2(300.0, -300.0)
## Upward velocity delta applied by the "Knock Up" button (negative Y = up).
const KNOCK_UP_VELOCITY := Vector2(0.0, -450.0)
## Best-effort collision proxy for the rig (which has no physics bodies): a
## static box matching the standing figure's world bounds (x ±120, y 0..-1000).
const RIG_PROXY_SIZE := Vector2(240.0, 1000.0)
@@ -51,6 +55,13 @@ const RIG_PROXY_CENTER := Vector2(0.0, -500.0)
var _is_panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
## The spawned rig instance (StickmanRig). Stored so the mode toggle can
## switch between Stickman (animated) and Ragdoll modes.
var _rig: StickmanRig = null
## Top-bar mode toggle button (text flips "Stickman" <-> "Ragdoll").
var _ragdoll_toggle: Button = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -59,6 +70,7 @@ func _ready() -> void:
_camera.make_current()
_build_environment()
_spawn_rig()
_build_ui()
# ---------------------------------------------------------------------------
# Input (camera zoom / pan)
@@ -69,20 +81,6 @@ func _input(event: InputEvent) -> void:
_handle_mouse_button(event as InputEventMouseButton)
elif event is InputEventMouseMotion:
_handle_mouse_motion(event as InputEventMouseMotion)
elif event is InputEventKey:
_handle_key(event as InputEventKey)
func _handle_key(key: InputEventKey) -> void:
if not key.pressed or key.echo:
return
match key.keycode:
KEY_1:
_spawn_prop_crate()
KEY_2:
_spawn_prop_ball()
KEY_3:
_spawn_prop_plank()
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
@@ -107,6 +105,80 @@ func _set_zoom(value: float) -> void:
var z := clampf(value, MIN_ZOOM, MAX_ZOOM)
_camera.zoom = Vector2(z, z)
# ---------------------------------------------------------------------------
# Top-bar UI
# ---------------------------------------------------------------------------
func _build_ui() -> void:
var ui := CanvasLayer.new()
ui.name = "UI"
add_child(ui)
var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = 40.0
ui.add_child(top_bar)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
top_bar.add_child(hbox)
var crate_btn := Button.new()
crate_btn.text = "Spawn Crate"
crate_btn.pressed.connect(_spawn_prop_crate)
hbox.add_child(crate_btn)
var ball_btn := Button.new()
ball_btn.text = "Spawn Ball"
ball_btn.pressed.connect(_spawn_prop_ball)
hbox.add_child(ball_btn)
var plank_btn := Button.new()
plank_btn.text = "Spawn Plank"
plank_btn.pressed.connect(_spawn_prop_plank)
hbox.add_child(plank_btn)
_ragdoll_toggle = Button.new()
_ragdoll_toggle.toggle_mode = true
_ragdoll_toggle.toggled.connect(_on_ragdoll_toggled)
hbox.add_child(_ragdoll_toggle)
_update_ragdoll_toggle()
var knock_btn := Button.new()
knock_btn.text = "Knock Up"
knock_btn.pressed.connect(_knock_up)
hbox.add_child(knock_btn)
func _on_ragdoll_toggled(pressed: bool) -> void:
if _rig == null:
return
_rig.set_ragdoll(pressed)
if pressed:
_remove_rig_collision_proxy()
else:
_add_rig_collision_proxy()
_update_ragdoll_toggle()
func _update_ragdoll_toggle() -> void:
if _ragdoll_toggle == null or _rig == null:
return
var in_ragdoll: bool = _rig.is_in_ragdoll()
_ragdoll_toggle.set_pressed_no_signal(in_ragdoll)
_ragdoll_toggle.text = "Ragdoll" if in_ragdoll else "Stickman"
func _knock_up() -> void:
# Ragdoll: boost every ragdoll body upward (preserves internal structure).
if _rig != null and _rig.is_in_ragdoll():
_rig.apply_ragdoll_velocity_boost(KNOCK_UP_VELOCITY)
# Dynamic props: same upward delta so everything flies together.
for child: Node in _environment.get_children():
if child is RigidBody2D and is_instance_valid(child):
var body := child as RigidBody2D
body.apply_central_impulse(KNOCK_UP_VELOCITY * body.mass)
# ---------------------------------------------------------------------------
# Environment / rig construction
# ---------------------------------------------------------------------------
@@ -144,12 +216,13 @@ func _build_environment() -> void:
func _spawn_rig() -> void:
var rig := RIG_SCENE.instantiate() as Node2D
var rig := RIG_SCENE.instantiate() as StickmanRig
if rig == null:
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
return
rig.position = RIG_SPAWN_POSITION
add_child(rig)
_rig = rig
_add_rig_collision_proxy()
# ---------------------------------------------------------------------------
@@ -189,6 +262,9 @@ func _spawn_prop_plank() -> void:
## The rig has no physics bodies, so a code-only StaticBody2D proxy provides a
## collision surface matching its standing bounds. Props bounce/rest against it.
func _add_rig_collision_proxy() -> void:
if _find_rig_collision_proxy() != null:
return
var proxy := StaticBody2D.new()
proxy.name = "RigCollisionProxy"
proxy.position = RIG_PROXY_CENTER
@@ -201,3 +277,16 @@ func _add_rig_collision_proxy() -> void:
proxy.add_child(shape)
add_child(proxy)
func _remove_rig_collision_proxy() -> void:
var proxy := _find_rig_collision_proxy()
if proxy != null:
proxy.queue_free()
func _find_rig_collision_proxy() -> Node:
for child: Node in get_children():
if child.name == "RigCollisionProxy":
return child
return null
+320
View File
@@ -19,6 +19,10 @@ 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
# ---------------------------------------------------------------------------
@@ -78,6 +82,55 @@ const Z_ORDER_BY_PROFILE: Dictionary = {
],
}
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
@@ -124,6 +177,10 @@ signal facing_profile_changed(profile: int)
## 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
# ---------------------------------------------------------------------------
@@ -134,6 +191,20 @@ 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
# ---------------------------------------------------------------------------
@@ -148,6 +219,10 @@ func _ready() -> void:
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).
@@ -165,6 +240,20 @@ func _ready() -> void:
# 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
@@ -221,6 +310,33 @@ func get_bend_joint_global_position(joint: String) -> Vector2:
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
# ---------------------------------------------------------------------------
@@ -306,3 +422,207 @@ func _apply_body_z_order() -> void:
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()