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)