Files
stickman/scripts/stickman_ragdoll.gd
ryan fbce638bc5 Refactor stickman rig and ragdoll mechanics; enhance pose restoration and stability
- Disabled constraints for the stickman rig's head bone to allow for smoother animations.
- Improved mode transitions in the sandbox stage to preserve stickman poses between EDIT and DIRECT modes.
- Added side profile stabilization for knee bends in the ragdoll component to prevent unnatural poses during physics interactions.
- Enhanced the snap-to-standing functionality to restore the stickman's original pose accurately.
- Introduced new tests for validating pose preservation during mode transitions and ragdoll behavior during walking animations.
- Created a new scene for testing stickman bending and ragdoll transitions.
2026-09-18 22:44:48 -04:00

788 lines
41 KiB
GDScript

class_name StickmanRagdoll
extends Node
## Owns procedural ragdoll physics. StickmanRig remains the public state facade.
signal recovery_requested()
signal rest_state_changed(at_rest: bool)
signal state_transition_requested(next_state: int)
const CONTAINER_NAME: StringName = &"RagdollBodyContainer"
const LIMB_RADIUS: float = 8.0
const TORSO_RADIUS: float = 12.0
const HEAD_RADIUS: float = 100.0
const VISUAL_COLOR: Color = Color(0.445488, 0.445488, 0.445488)
const CIRCLE_SEGMENTS: int = 32
const REST_LINEAR_THRESHOLD: float = 5.0
const REST_ANGULAR_THRESHOLD: float = 0.1
const STABILIZATION_DELAY: float = 0.1
const TARGET_SOFTNESS: float = 0.2
const SIDE_PROFILE_STRAIGHT_THRESHOLD := deg_to_rad(5.0)
const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0)
const GROUND_PROBE_START_OFFSET := 50.0
const GROUND_PROBE_END_OFFSET := 1500.0
const GROUND_PROBE_TOLERANCE := 200.0
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
const STAND_UP_DURATION := 2.0
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},
}
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",
}
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"],
}
const LIMB_BODY_KEYS: Dictionary = {
"LeftArm": ["left_upper_arm", "left_lower_arm"],
"RightArm": ["right_upper_arm", "right_lower_arm"],
"LeftLeg": ["left_upper_leg", "left_lower_leg"],
"RightLeg": ["right_upper_leg", "right_lower_leg"],
}
const RECOVERY_LEG_SIDE_FORCE: Dictionary = {
StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {},
}
const RECOVERY_JOINT_PATH: Dictionary = {
StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {},
}
const BODIES: Array[Dictionary] = [
{ "key": "torso", "kind": "bone", "node_path": "Torso", "visual_path": "Body/Body", "radius": TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 },
{ "key": "head", "kind": "visual", "node_path": "Body/Head", "visual_path": "Body/Head", "radius": HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 },
{ "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "visual_path": "Body/LeftUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "visual_path": "Body/LeftLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "visual_path": "Body/RightUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "visual_path": "Body/RightLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "visual_path": "Body/LeftUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "visual_path": "Body/LeftLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "visual_path": "Body/RightUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "visual_path": "Body/RightLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
]
const JOINTS: Array[Dictionary] = [
{ "child": "head", "parent": "torso", "pin": "Torso/Head", "limit": "neck" }, { "child": "left_upper_arm", "parent": "torso", "pin": "Torso/LeftUpperArm", "limit": "shoulder" }, { "child": "left_lower_arm", "parent": "left_upper_arm", "pin": "Torso/LeftUpperArm/LeftLowerArm", "limit": "cw" }, { "child": "right_upper_arm", "parent": "torso", "pin": "Torso/RightUpperArm", "limit": "shoulder" }, { "child": "right_lower_arm", "parent": "right_upper_arm", "pin": "Torso/RightUpperArm/RightLowerArm", "limit": "ccw" }, { "child": "left_upper_leg", "parent": "torso", "pin": "Torso/LeftUpperLeg", "limit": "shoulder" }, { "child": "left_lower_leg", "parent": "left_upper_leg", "pin": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "ccw" }, { "child": "right_upper_leg", "parent": "torso", "pin": "Torso/RightUpperLeg", "limit": "shoulder" }, { "child": "right_lower_leg", "parent": "right_upper_leg", "pin": "Torso/RightUpperLeg/RightLowerLeg", "limit": "cw" },
]
## Per-profile elbow/knee limits. Side-profile leg hinges include the straight
## pose (0 degrees) and allow both knees to fold toward the same profile-relative
## side, avoiding an instant forced +/-90-degree fold at ragdoll handoff.
## The component owns these settings because it creates the PinJoint2D network.
const JOINT_BAND_LIMITS: Dictionary = {
StickmanRig.FacingProfile.FORWARD: {
"left_lower_arm": { "lower": -150.0, "upper": 5.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": -5.0, "upper": 150.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 },
},
StickmanRig.FacingProfile.LEFT: {
"left_lower_arm": { "lower": 60.0, "upper": 100.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": -150.0, "upper": 0.0 }, "right_lower_leg": { "lower": -150.0, "upper": 0.0 },
},
StickmanRig.FacingProfile.RIGHT: {
"left_lower_arm": { "lower": -100.0, "upper": -60.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": 0.0, "upper": 150.0 }, "right_lower_leg": { "lower": 0.0, "upper": 150.0 },
},
}
var _rig: StickmanRig = null
var _locomotion: StickmanLocomotion = null
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _anim_player: AnimationPlayer = null
var _torso_bone: Bone2D = null
var _head_bone: Bone2D = null
var _head_look_at: SkeletonModification2DLookAt = null
var _limb_bones: Dictionary = {}
var _bend_modifications: Dictionary = {}
var _root: Node2D = null
var _bodies: Dictionary = {}
var _previous_position: Vector2 = Vector2.ZERO
var _previous_rotation: float = 0.0
var _linear_velocity: Vector2 = Vector2.ZERO
var _angular_velocity: float = 0.0
var _rest_timer: float = 0.0
var _stabilize_timer: float = 0.0
var _at_rest: bool = false
var _captured_pose: Dictionary = {}
var _captured_landing_center := Vector2.ZERO
var _captured_ground_y := 0.0
var _stand_up_tween: Tween = null
var _stand_up_generation := 0
var _recovery_bones: Array[Dictionary] = []
var _pre_ragdoll_state: Dictionary = {}
var _probe_top_y := 0.0
var _probe_bottom_y := 0.0
var _probe_exclude: Array[RID] = []
var _spawn_pose: Dictionary = {}
var _spawn_limb_rotations: Dictionary = {}
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
_rig = rig
_locomotion = locomotion
func initialize(skeleton: Skeleton2D) -> void:
_skeleton = skeleton
_body_container = _rig.get_node_or_null(NodePath("Body")) as Node2D
_anim_player = _rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
if _skeleton != null:
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
_resolve_limb_bones()
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
for index: int in stack.modification_count:
var modification: SkeletonModification2D = stack.get_modification(index)
if modification is SkeletonModification2DLookAt:
_head_look_at = modification as SkeletonModification2DLookAt
elif modification is SkeletonModification2DTwoBoneIK:
var ik := modification as SkeletonModification2DTwoBoneIK
for joint: String in LIMB_BONE_PATHS:
if ik.joint_two_bone2d_node == NodePath(String((LIMB_BONE_PATHS[joint] as Array)[1])):
_bend_modifications[joint] = ik
break
_previous_position = _rig.global_position
_previous_rotation = _rig.global_rotation
## Runs after the physics step and before drawing. Godot's PinJoint2D can let a
## straight knee cross its angular limit during the step; correcting here keeps
## that invalid intermediate pose from ever reaching the rendered frame.
func _process(_delta: float) -> void:
if _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
_enforce_side_profile_leg_bends()
func _resolve_limb_bones() -> void:
_limb_bones.clear()
for joint: String in LIMB_BONE_PATHS:
var paths: Array = LIMB_BONE_PATHS[joint]
var upper := _skeleton.get_node_or_null(NodePath(String(paths[0]))) as Bone2D
var lower := _skeleton.get_node_or_null(NodePath(String(paths[1]))) as Bone2D
if upper != null and lower != null:
_limb_bones[joint] = {"upper": upper, "lower": lower}
func tick_pre_movement(delta: float, rig_state: int, rest_timeout: float, auto_recover: bool) -> void:
if _rig == null: return
if delta > 0.0:
_linear_velocity = (_rig.global_position - _previous_position) / delta
_angular_velocity = wrapf(_rig.global_rotation - _previous_rotation, -PI, PI) / delta
_previous_position = _rig.global_position
_previous_rotation = _rig.global_rotation
if rig_state != StickmanRig.RigState.RAGDOLL: return
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
if torso == null or not is_instance_valid(torso): return
var resting: bool = torso.sleeping or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
if resting != _at_rest: _at_rest = resting; rest_state_changed.emit(resting)
if not resting: _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:
_stabilize_timer += delta
if _stabilize_timer >= STABILIZATION_DELAY: recovery_requested.emit()
func build() -> bool:
if _rig == null or _skeleton == null: return false
var parent: Node = _rig.get_parent()
if parent == null: parent = _rig.get_tree().current_scene
if parent == null: return false
_root = Node2D.new(); _root.name = CONTAINER_NAME; parent.add_child(_root); _bodies.clear()
for entry: Dictionary in BODIES: _build_body(entry)
_stabilize_side_profile_leg_bends()
for entry: Dictionary in JOINTS: _build_joint(entry)
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
if torso != null: torso.linear_velocity = _linear_velocity; torso.angular_velocity = _angular_velocity
reset_rest_state()
return torso != null
## A walk keyframe can put a leg exactly straight. At that singularity the
## physics solver has no geometric knee side to preserve, so floating-point
## noise can make either knee fold as if the opposite facing profile were in
## use. Give only near-straight side-profile legs a small world-space bend
## toward the profile's established rear side before the joints are created.
func _stabilize_side_profile_leg_bends() -> void:
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
return
var desired_delta := SIDE_PROFILE_KNEE_BIAS \
if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT \
else -SIDE_PROFILE_KNEE_BIAS
for prefix: String in ["left", "right"]:
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
if upper == null or lower == null:
continue
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
if absf(current_delta) > SIDE_PROFILE_STRAIGHT_THRESHOLD:
continue
var upper_half := float(upper.get_meta("half_height", 0.0))
var lower_half := float(lower.get_meta("half_height", 0.0))
var upper_dir := Vector2.from_angle(upper.global_rotation)
var knee := upper.global_position + upper_dir * upper_half
var lower_dir := upper_dir.rotated(desired_delta)
lower.global_rotation = lower_dir.angle()
lower.global_position = knee + lower_dir * lower_half
## PinJoint2D can cross its angular limit near a perfectly straight chain in
## Godot 4.7.1. Correct only an actual side crossing so a knee cannot select
## the opposite solution. Normal motion on the intended side remains physical.
func _enforce_side_profile_leg_bends() -> void:
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
return
var desired_side := -1.0 if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT else 1.0
var desired_delta_sign := -desired_side
for prefix: String in ["left", "right"]:
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
continue
var upper_half := float(upper.get_meta("half_height", 0.0))
var lower_half := float(lower.get_meta("half_height", 0.0))
var upper_dir := Vector2.from_angle(upper.global_rotation)
var knee := upper.global_position + upper_dir * upper_half
var hip := upper.global_position - upper_dir * upper_half
var lower_dir_current := Vector2.from_angle(lower.global_rotation)
var ankle := lower.global_position + lower_dir_current * lower_half
var current_side := (ankle - hip).cross(knee - hip)
if current_side * desired_side > 0.0:
continue
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
var corrected_delta := desired_delta_sign * minf(
maxf(absf(current_delta), SIDE_PROFILE_KNEE_BIAS), deg_to_rad(150.0))
var lower_dir := upper_dir.rotated(corrected_delta)
lower.global_rotation = lower_dir.angle()
lower.global_position = knee + lower_dir * lower_half
lower.angular_velocity = upper.angular_velocity
func destroy() -> void:
if _root != null and is_instance_valid(_root): _root.name = StringName(str(CONTAINER_NAME) + "_retired"); _root.queue_free()
_root = null; _bodies.clear(); reset_rest_state()
func get_bodies() -> Dictionary: return _bodies
func is_at_rest() -> bool: return _at_rest
func reset_rest_state() -> void: _at_rest = false; _rest_timer = 0.0; _stabilize_timer = 0.0
func apply_velocity_boost(velocity: Vector2) -> void:
for body: RigidBody2D in _bodies.values():
if body != null and is_instance_valid(body): body.apply_central_impulse(velocity * body.mass)
func enter_ragdoll() -> bool:
if _rig == null or _skeleton == null or _body_container == null:
push_warning("StickmanRagdoll: cannot enter ragdoll; missing rig nodes.")
return false
_capture_pre_ragdoll_state()
if _locomotion != null:
_locomotion.clear_impact_reactions()
_locomotion.set_collision_bodies_enabled(false)
_locomotion.cancel_walk(false)
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop(true)
if not build():
return false
_body_container.visible = false
_body_container.modulate.a = 1.0
if _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = false
reset_rest_state()
return true
func request_recovery() -> bool:
if _rig == null or _rig.state != StickmanRig.RigState.RAGDOLL:
return false
_capture_ragdoll_pose()
destroy()
_reanchor_root_to_landing()
state_transition_requested.emit(StickmanRig.RigState.RECOVERING)
_snap_skeleton_to_pose()
if _locomotion != null:
_locomotion.set_collision_bodies_enabled(true)
_play_stand_up()
return true
func cancel_recovery() -> void:
_stand_up_generation += 1
if _stand_up_tween != null and _stand_up_tween.is_valid():
_stand_up_tween.kill()
_stand_up_tween = null
func snap_to_standing(previous_state: int) -> void:
if _locomotion != null:
_locomotion.set_terrain_following_enabled(false)
if previous_state == StickmanRig.RigState.RAGDOLL:
destroy()
elif previous_state == StickmanRig.RigState.RECOVERING:
cancel_recovery()
if _locomotion != null:
_locomotion.clear_impact_reactions(true)
_rig._hide_speech()
# The factory captures the adapted skeleton's real authored spawn pose. That
# is also the pose shown by the placement ghost, so every forced reset must
# restore it instead of the legacy, one-size-fits-all STAND_POSE fallback.
_restore_standing_markers(true)
_rearm_ik_stack()
_set_limb_mods_enabled(true)
if previous_state == StickmanRig.RigState.ANIMATED:
_rig._apply_head_lookat_mirror_mode()
else:
_restore_head_lookat_state()
_recovery_bones.clear()
reset_rest_state()
_clear_recovery_capture()
if _locomotion != null:
_locomotion.set_collision_bodies_enabled(true)
if _body_container != null:
_body_container.visible = true
_body_container.modulate.a = 1.0
_rig._apply_idle_limb_solver_mode(_spawn_limb_rotations)
if previous_state != StickmanRig.RigState.ANIMATED:
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
func initialize_spawn_pose_from_skeleton() -> void:
if _rig == null:
return
var skeleton := _rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
if skeleton == null:
push_warning("StickmanRagdoll: cannot initialize spawn pose; missing Skeleton2D.")
return
var pose: Dictionary = {}
var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
if torso != null:
pose["Torso"] = {"pos": _rig.to_local(torso.global_position), "rot": torso.rotation}
var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head != null:
var head_tip := head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle)))
pose["Head"] = {"pos": _rig.to_local(head_tip), "rot": 0.0}
var endpoints: Dictionary = {
"Left_Hand": "Torso/LeftUpperArm/LeftLowerArm", "Right_Hand": "Torso/RightUpperArm/RightLowerArm",
"Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg", "Right_Leg": "Torso/RightUpperLeg/RightLowerLeg",
}
for marker_name: String in endpoints:
var bone := skeleton.get_node_or_null(NodePath(String(endpoints[marker_name]))) as Bone2D
if bone == null:
return
var endpoint := bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
pose[marker_name] = {"pos": _rig.to_local(endpoint), "rot": 0.0}
if pose.size() != IK_TARGET_PATHS.size():
return
for marker_name: String in pose:
var marker := _rig.get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D
if marker == null:
return
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
marker.rotation = target.get("rot", marker.rotation)
if marker_name == "Left_Leg" or marker_name == "Right_Leg":
var base_marker := _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D
if base_marker != null:
base_marker.position = marker.position
_spawn_pose = pose.duplicate(true)
_spawn_limb_rotations.clear()
for joint: String in LIMB_BONE_PATHS:
var pair: Array = LIMB_BONE_PATHS[joint]
var upper := skeleton.get_node_or_null(NodePath(String(pair[0]))) as Bone2D
var lower := skeleton.get_node_or_null(NodePath(String(pair[1]))) as Bone2D
if upper != null and lower != null:
_spawn_limb_rotations[joint] = {"upper": upper.rotation, "lower": lower.rotation}
func get_spawn_limb_rotations() -> Dictionary:
return _spawn_limb_rotations
func get_spawn_pose() -> Dictionary:
return _spawn_pose.duplicate(true)
func get_captured_pose() -> Dictionary:
return _captured_pose
func _capture_pre_ragdoll_state() -> void:
_pre_ragdoll_state.clear()
var markers: Dictionary = {}
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker != null:
markers[marker_name] = {"pos": marker.position, "rot": marker.rotation}
_pre_ragdoll_state["markers"] = markers
var rotations: Dictionary = {}
for joint: String in _limb_bones:
var pair: Dictionary = _limb_bones[joint]
var upper := pair.get("upper") as Bone2D
var lower := pair.get("lower") as Bone2D
rotations[joint] = {"upper": upper.rotation if upper != null else 0.0, "lower": lower.rotation if lower != null else 0.0}
_pre_ragdoll_state["limb_rotations"] = rotations
_pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null else 0.0
_pre_ragdoll_state["facing_profile"] = int(_rig.facing_profile)
var bends: Dictionary = {}
for joint: String in StickmanRig.BEND_JOINTS:
bends[joint] = _rig.get_joint_bend_flipped(joint)
_pre_ragdoll_state["bends"] = bends
_pre_ragdoll_state["head_lookat_enabled"] = _head_look_at.enabled if _head_look_at != null else false
var leg_sides: Dictionary = {}
for leg_joint: String in ["LeftLeg", "RightLeg"]:
var pair: Dictionary = _limb_bones.get(leg_joint, {})
var upper := pair.get("upper") as Bone2D
var lower := pair.get("lower") as Bone2D
var marker := _get_ik_marker("Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg")
if upper == null or lower == null or marker == null:
continue
var side := (marker.global_position - upper.global_position).cross(lower.global_position - upper.global_position)
leg_sides[leg_joint] = {"side": 1.0 if side >= 0.0 else -1.0, "flag": _rig.get_joint_bend_flipped(leg_joint)}
_pre_ragdoll_state["leg_sides"] = leg_sides
var torso_entry_rot := 0.0
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
torso_entry_rot = torso_marker.rotation
var spine_entry_rel := 0.0
if _head_bone != null and _torso_bone != null:
spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - _rig.global_rotation
_pre_ragdoll_state["torso_chain_offset"] = _chain_angle_from_captured(spine_entry_rel) - torso_entry_rot
var head_entry_chain := torso_entry_rot + float(_pre_ragdoll_state.get("head_rotation", 0.0))
_pre_ragdoll_state["head_chain_offset"] = _chain_angle_from_captured(-_rig.global_rotation) - head_entry_chain
func _capture_ragdoll_pose() -> void:
_captured_pose.clear()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
var top_y := INF
var bottom_y := -INF
var exclude_rids: Array[RID] = []
for key: String in _bodies:
var body := _bodies[key] as RigidBody2D
if body == null or not is_instance_valid(body):
continue
var half := float(body.get_meta("half_height", 0.0))
var radius := float(body.get_meta("radius", 0.0))
var extent := half + radius
top_y = minf(top_y, body.global_position.y - extent)
bottom_y = maxf(bottom_y, body.global_position.y + extent)
exclude_rids.append(body.get_rid())
_captured_pose[key] = {"pos": _rig.to_local(body.global_position), "rot": body.global_rotation - _rig.global_rotation, "half": half}
var torso := _bodies.get("torso") as RigidBody2D
if torso != null and is_instance_valid(torso):
_captured_landing_center = torso.global_position
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)
if is_equal_approx(_captured_ground_y, bottom_y):
_captured_ground_y = torso.global_position.y + float(torso.get_meta("radius", 0.0))
_probe_top_y = top_y
_probe_bottom_y = bottom_y
_probe_exclude = exclude_rids
func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
if _locomotion != null:
return _locomotion.probe_ground_y(world_x, top_y, bottom_y, exclude)
var best_y := bottom_y
var space_state := _rig.get_world_2d().direct_space_state
if space_state == null:
return best_y
var best_dist := INF
for offset: float in GROUND_PROBE_X_OFFSETS:
var query := PhysicsRayQueryParameters2D.create(Vector2(world_x + offset, top_y - GROUND_PROBE_START_OFFSET), Vector2(world_x + offset, bottom_y + GROUND_PROBE_END_OFFSET))
query.collision_mask = 1
query.exclude = exclude
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var y := (hit.get("position", Vector2.ZERO) as Vector2).y
if y < top_y - GROUND_PROBE_TOLERANCE or y > bottom_y + GROUND_PROBE_TOLERANCE:
continue
var distance := absf(y - bottom_y)
if distance < best_dist:
best_dist = distance
best_y = y
return best_y
func _reanchor_root_to_landing() -> void:
if not _captured_pose.has("torso"):
return
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
var old_root := _rig.global_position
var foot_offset := _locomotion.get_foot_offset() if _locomotion != null else Vector2(0.0, -385.0)
_rig.global_position = feet_world + foot_offset
var shift := old_root - _rig.global_position
var local_shift := _rig.to_local(shift) - _rig.to_local(Vector2.ZERO)
for key: String in _captured_pose:
var entry: Dictionary = _captured_pose[key]
entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift
func _clear_recovery_capture() -> void:
_captured_pose.clear()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
_pre_ragdoll_state.clear()
_probe_top_y = 0.0
_probe_bottom_y = 0.0
_probe_exclude.clear()
func _chain_angle_from_captured(captured_rel: float) -> float:
return -captured_rel if _rig.facing_profile == StickmanRig.FacingProfile.LEFT else captured_rel
func _wrap_toward(value: float, target: float) -> float:
return target + wrapf(value - target, -PI, PI)
func _natural_joint_target(current: float, target: float, offset: float) -> float:
var a := wrapf(current + offset, -PI, PI)
var b := wrapf(target + offset, -PI, PI)
var delta := wrapf(b - a, -PI, PI)
if a * b < 0.0 and absf(a) + absf(b) > PI:
delta += TAU if delta < 0.0 else -TAU
return current + delta
func _set_limb_mods_enabled(enabled: bool) -> void:
for joint: String in StickmanRig.BEND_JOINTS:
var modification := _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if modification != null and is_instance_valid(modification):
modification.enabled = enabled
func _restore_head_lookat_state() -> void:
if _head_look_at != null and is_instance_valid(_head_look_at):
_head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false))
func _rearm_ik_stack() -> void:
if _skeleton == null or not is_instance_valid(_skeleton):
return
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
if not stack.get_is_setup():
stack.setup()
stack.enabled = true
_skeleton.set_process_internal(true)
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
if driver != null:
driver.update_position = true
driver.update_rotation = true
driver.update_scale = true
func _restore_standing_markers(use_spawn_pose: bool = true) -> void:
var pose := _spawn_pose if use_spawn_pose and not _spawn_pose.is_empty() else STAND_POSE
for marker_name: String in pose:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
func _get_ik_marker(marker_name: String) -> Marker2D:
if _rig == null:
return null
var path := String(IK_TARGET_PATHS.get(marker_name, ""))
return _rig.get_node_or_null(NodePath(path)) as Marker2D if not path.is_empty() else null
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
var marker := _get_ik_marker(marker_name)
var pose: Dictionary = _captured_pose.get(body_key, {})
if marker == null or pose.is_empty():
return
var direction := Vector2.from_angle(float(pose.get("rot", 0.0)) + _rig.global_rotation)
var center_world := _rig.to_global(pose.get("pos", Vector2.ZERO))
marker.position = _rig.to_local(center_world + direction * float(pose.get("half", 0.0)))
func _snap_skeleton_to_pose() -> void:
_recovery_bones.clear()
var pre_markers: Dictionary = _pre_ragdoll_state.get("markers", {})
var pre_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
var pre_head_rotation := float(_pre_ragdoll_state.get("head_rotation", 0.0))
var torso_chain_offset := float(_pre_ragdoll_state.get("torso_chain_offset", 0.0))
var head_chain_offset := float(_pre_ragdoll_state.get("head_chain_offset", 0.0))
var theta_t := 0.0
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 torso_rel := float(torso_pose.get("rot", 0.0))
var spine_dir := Vector2.from_angle(torso_rel + _rig.global_rotation)
var center_world := _rig.to_global(torso_pose.get("pos", Vector2.ZERO))
torso_marker.position = _rig.to_local(center_world - spine_dir * float(torso_pose.get("half", 0.0)))
var raw_rotation := _chain_angle_from_captured(torso_rel) - torso_chain_offset
var pre_torso_rotation := float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rotation))
theta_t = wrapf(_wrap_toward(raw_rotation, pre_torso_rotation), -PI, PI)
torso_marker.rotation = theta_t
var head_marker := _get_ik_marker("Head")
var head_pose: Dictionary = _captured_pose.get("head", {})
if head_marker != null and 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")
for marker_name: String in ["Left_Leg", "Right_Leg"]:
var marker := _get_ik_marker(marker_name)
var body_key := "left_lower_leg" if marker_name == "Left_Leg" else "right_lower_leg"
if marker == null or (_captured_pose.get(body_key, {}) as Dictionary).is_empty():
continue
var ankle_world := _rig.to_global(marker.position)
var ground_y := _probe_ground_y(ankle_world.x, _probe_top_y, _probe_bottom_y, _probe_exclude)
if ankle_world.y < ground_y:
ankle_world.y = ground_y
marker.position = _rig.to_local(ankle_world)
if not (_captured_pose.get("torso", {}) as Dictionary).is_empty():
for joint: String in LIMB_BONE_PATHS:
var pair: Dictionary = _limb_bones.get(joint, {})
var upper := pair.get("upper") as Bone2D
var lower := pair.get("lower") as Bone2D
if upper == null or lower == null:
continue
var keys: Array = LIMB_BODY_KEYS[joint]
var upper_pose: Dictionary = _captured_pose.get(keys[0], {})
var lower_pose: Dictionary = _captured_pose.get(keys[1], {})
if upper_pose.is_empty() or lower_pose.is_empty():
continue
var upper_chain := _chain_angle_from_captured(float(upper_pose.get("rot", 0.0)))
var lower_chain := _chain_angle_from_captured(float(lower_pose.get("rot", 0.0)))
var pre_pair: Dictionary = pre_limb_rotations.get(joint, {})
upper.rotation = _wrap_toward(upper_chain - theta_t - deg_to_rad(upper.bone_angle), float(pre_pair.get("upper", upper.rotation)))
lower.rotation = _wrap_toward(lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle), float(pre_pair.get("lower", lower.rotation)))
upper.rotation = wrapf(upper.rotation, -PI, PI)
lower.rotation = wrapf(lower.rotation, -PI, PI)
if joint == "LeftLeg" or joint == "RightLeg":
continue
var target_upper := upper.rotation + wrapf(float(pre_pair.get("upper", upper.rotation)) - upper.rotation, -PI, PI)
var pre_lower := float(pre_pair.get("lower", lower.rotation))
var path_override := String((RECOVERY_JOINT_PATH.get(_rig.facing_profile, {}) as Dictionary).get(joint, ""))
var target_lower := lower.rotation + wrapf(pre_lower - lower.rotation, -PI, PI) if path_override == "shortest" else _natural_joint_target(lower.rotation, pre_lower, deg_to_rad(lower.bone_angle - upper.bone_angle))
_recovery_bones.append({"bone": upper, "target": target_upper})
_recovery_bones.append({"bone": lower, "target": target_lower})
if not head_pose.is_empty() and _head_bone != null:
var raw_head := _chain_angle_from_captured(float(head_pose.get("rot", 0.0))) - head_chain_offset - theta_t
_head_bone.rotation = wrapf(_wrap_toward(raw_head, pre_head_rotation), -PI, PI)
_recovery_bones.append({"bone": _head_bone, "target": _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)})
_set_limb_mods_enabled(false)
if _head_look_at != null:
_head_look_at.enabled = false
_rearm_ik_stack()
for leg_joint: String in ["LeftLeg", "RightLeg"]:
var modification := _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK
if modification == null:
continue
var entry: Dictionary = (_pre_ragdoll_state.get("leg_sides", {}) as Dictionary).get(leg_joint, {})
var forced: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(_rig.facing_profile, {})
if forced.has(leg_joint):
modification.flip_bend_direction = bool(forced[leg_joint])
elif not entry.is_empty():
var upper_key := "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg"
var lower_key := "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg"
var up_pose: Dictionary = _captured_pose.get(upper_key, {})
var low_pose: Dictionary = _captured_pose.get(lower_key, {})
if not up_pose.is_empty() and not low_pose.is_empty():
var dir_u := Vector2.from_angle(float(up_pose.get("rot", 0.0)) + _rig.global_rotation)
var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + _rig.global_rotation)
var center_u := _rig.to_global(up_pose.get("pos", Vector2.ZERO))
var hip := center_u - dir_u * float(up_pose.get("half", 0.0))
var knee := center_u + dir_u * float(up_pose.get("half", 0.0))
var ankle := _rig.to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * float(low_pose.get("half", 0.0))
var captured_side := 1.0 if (ankle - hip).cross(knee - hip) >= 0.0 else -1.0
modification.flip_bend_direction = bool(entry.get("flag", false)) if captured_side == float(entry.get("side", captured_side)) else not bool(entry.get("flag", false))
modification.enabled = true
if _body_container != null:
_body_container.visible = true
_body_container.modulate.a = 1.0
func _play_stand_up() -> void:
_stand_up_generation += 1
var generation := _stand_up_generation
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
for entry: Dictionary in _recovery_bones:
var bone := entry.get("bone") as Bone2D
if bone != null:
_stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION)
_stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation))
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
var tween := create_tween()
tween.set_parallel(true).set_trans(Tween.TRANS_SINE).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
func _on_stand_up_finished(generation: int) -> void:
if generation != _stand_up_generation or _rig.state != StickmanRig.RigState.RECOVERING:
return
var recovered_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
_rig.set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(_rig.facing_profile))))
var bends: Dictionary = _pre_ragdoll_state.get("bends", {})
for joint: String in StickmanRig.BEND_JOINTS:
if bends.has(joint):
_rig.set_joint_bend_flipped(joint, bool(bends[joint]))
_set_limb_mods_enabled(true)
_restore_head_lookat_state()
_recovery_bones.clear()
_rearm_ik_stack()
if _body_container != null:
_body_container.visible = true
_body_container.modulate.a = 1.0
if _locomotion != null:
_locomotion.set_collision_bodies_enabled(true)
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
_rig._apply_idle_limb_solver_mode(recovered_rotations)
func _build_body(entry: Dictionary) -> void:
var key: String = str(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.collision_layer = 1; body.collision_mask = 1
body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true)
var shape := CollisionShape2D.new()
if str(entry["kind"]) == "visual":
var visual := _rig.get_node_or_null(NodePath(str(entry["node_path"]))) as Node2D
if visual == null: return
var circle := CircleShape2D.new(); circle.radius = float(entry["radius"]); shape.shape = circle; body.add_child(shape); body.position = visual.global_position; body.set_meta("half_height", 0.0); body.set_meta("radius", float(entry["radius"]))
if not _clone_visuals(body, str(entry["visual_path"])): _add_circle(body, float(entry["radius"]))
else:
var bone := _skeleton.get_node_or_null(NodePath(str(entry["node_path"]))) as Bone2D
if bone == null: return
var origin: Vector2 = bone.global_position; var tip: Vector2
if key == "torso":
var head := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head == null: return
tip = head.global_position
else: tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
var length: float = origin.distance_to(tip); var capsule := CapsuleShape2D.new(); capsule.height = length; capsule.radius = float(entry["radius"]); shape.shape = capsule; shape.rotation = -PI * 0.5; body.add_child(shape); body.position = (origin + tip) * 0.5; body.rotation = (tip - origin).angle(); body.set_meta("half_height", length * 0.5); body.set_meta("radius", float(entry["radius"]))
if not _clone_visuals(body, str(entry["visual_path"])): _add_capsule(body, length, float(entry["radius"]))
_root.add_child(body); _bodies[key] = body
func _build_joint(entry: Dictionary) -> void:
var child := _bodies.get(str(entry["child"])) as RigidBody2D; var parent := _bodies.get(str(entry["parent"])) as RigidBody2D; var bone := _skeleton.get_node_or_null(NodePath(str(entry["pin"]))) as Bone2D
if child == null or parent == null or bone == null: return
var pin := PinJoint2D.new(); pin.name = "RagdollPin_" + str(entry["child"]); pin.position = bone.global_position; _root.add_child(pin); pin.node_a = pin.get_path_to(parent); pin.node_b = pin.get_path_to(child); pin.softness = TARGET_SOFTNESS
_apply_joint_limits(pin, str(entry["limit"]), str(entry["child"]))
func _apply_joint_limits(pin: PinJoint2D, limit: String, child_key: String) -> void:
if limit == "shoulder":
pin.angular_limit_enabled = true; pin.angular_limit_lower = -deg_to_rad(160.0); pin.angular_limit_upper = deg_to_rad(160.0)
elif limit != "neck":
var profile: int = int(_rig.facing_profile) if _rig != null else StickmanRig.FacingProfile.FORWARD
var band: Dictionary = JOINT_BAND_LIMITS.get(profile, {}).get(child_key, {})
if band.is_empty(): band = { "lower": -5.0 if limit == "cw" else -150.0, "upper": 150.0 if limit == "cw" else 5.0 }
pin.angular_limit_enabled = true; pin.angular_limit_lower = deg_to_rad(float(band["lower"])); pin.angular_limit_upper = deg_to_rad(float(band["upper"]))
else: pin.angular_limit_enabled = false
func _clone_visuals(body: RigidBody2D, path: String) -> bool:
var source := _rig.get_node_or_null(NodePath(path)) as Node2D
if source == null: return false
var count: int = 0
for child: Node in source.get_children():
if child is Line2D:
var src := child as Line2D; var copy := Line2D.new(); var points := PackedVector2Array()
for point: Vector2 in src.points: points.append(body.to_local(src.to_global(point)))
copy.points = points; copy.width = src.width; copy.default_color = src.default_color; copy.begin_cap_mode = src.begin_cap_mode; copy.end_cap_mode = src.end_cap_mode; copy.joint_mode = src.joint_mode; copy.closed = src.closed; body.add_child(copy); count += 1
elif child is Polygon2D:
var src := child as Polygon2D; var copy := Polygon2D.new(); var points := PackedVector2Array()
for point: Vector2 in src.polygon: points.append(body.to_local(src.to_global(point)))
copy.polygon = points; copy.color = src.color; body.add_child(copy); count += 1
return count > 0
func _add_capsule(body: RigidBody2D, length: float, radius: float) -> void:
var line := Line2D.new(); line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)]); line.width = radius * 2.0; line.default_color = VISUAL_COLOR; line.begin_cap_mode = Line2D.LINE_CAP_ROUND; line.end_cap_mode = Line2D.LINE_CAP_ROUND; body.add_child(line)
func _add_circle(body: RigidBody2D, radius: float) -> void:
var polygon := Polygon2D.new(); var points := PackedVector2Array()
for index: int in CIRCLE_SEGMENTS: points.append(Vector2.from_angle(TAU * float(index) / float(CIRCLE_SEGMENTS)) * radius)
polygon.polygon = points; polygon.color = Color.WHITE; body.add_child(polygon)