class_name StickmanRig extends Node2D ## StickmanRig - Runtime owner of facing direction and per-joint bone bend for ## master_rig.tscn (Phase 9 / Task 4). ## ## Attached to the `Master` root node of master_rig.tscn. Owns the facing ## preset, the four per-joint TwoBoneIK "Flip Bend Direction" flags, and the ## Body/* draw order. Non-@tool: node resolution, flag writes and z-order ## reordering run only at runtime (_ready + setters on a live instance). # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- ## Facing profiles. Values are used directly as facing-menu item ids in the ## harness (0/1/2), so they must stay stable. enum FacingProfile { LEFT, RIGHT, FORWARD } ## Per-joint bend direction. INVERTED == flip_bend_direction = true. enum BendDirection { NORMAL, INVERTED } ## Rig physics mode. ANIMATED drives the skeleton + IK; RAGDOLL swaps in a ## procedural RigidBody2D + PinJoint2D network (see _build_ragdoll). enum RigState { ANIMATED, RAGDOLL } # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- const SKELETON_PATH := "Skeleton2D" const BODY_CONTAINER_PATH := "Body" ## Bend joints (upper↔lower limb connectors) whose TwoBoneIK "Flip Bend ## Direction" flag is user-controllable. const BEND_JOINTS: Array[String] = ["LeftArm", "RightArm", "LeftLeg", "RightLeg"] ## Lower-bone NodePath (relative to Skeleton2D) per bend joint. Used both to ## resolve the TwoBoneIK modification (via joint_two_bone2d_node) and to report ## each joint's world position for hit-testing. const BEND_JOINT_BONE_PATHS: Dictionary = { "LeftArm": "Torso/LeftUpperArm/LeftLowerArm", "RightArm": "Torso/RightUpperArm/RightLowerArm", "LeftLeg": "Torso/LeftUpperLeg/LeftLowerLeg", "RightLeg": "Torso/RightUpperLeg/RightLowerLeg", } ## flip_bend_direction value per facing profile, keyed by bend-joint name. const PROFILE_FLAGS: Dictionary = { FacingProfile.LEFT: { "LeftArm": false, "RightArm": false, "LeftLeg": true, "RightLeg": true }, FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false }, FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false }, } ## Body/* visual part node names in draw order (back-to-front) per profile. ## First entry backmost, last frontmost. Upper limbs behind lower limbs; on the ## far (behind-torso) side the arm pair draws behind the leg pair, on the near ## side the arm pair draws in front; head always frontmost. const Z_ORDER_BY_PROFILE: Dictionary = { FacingProfile.FORWARD: [ "Body", "LeftUpperLeg", "RightUpperLeg", "LeftLowerLeg", "RightLowerLeg", "LeftUpperArm", "RightUpperArm", "LeftLowerArm", "RightLowerArm", "Head", ], FacingProfile.LEFT: [ "LeftUpperArm", "LeftLowerArm", "LeftUpperLeg", "LeftLowerLeg", "Body", "RightUpperLeg", "RightLowerLeg", "RightUpperArm", "RightLowerArm", "Head", ], FacingProfile.RIGHT: [ "RightUpperArm", "RightLowerArm", "RightUpperLeg", "RightLowerLeg", "Body", "LeftUpperLeg", "LeftLowerLeg", "LeftUpperArm", "LeftLowerArm", "Head", ], } # --------------------------------------------------------------------------- # Ragdoll constants # --------------------------------------------------------------------------- const ANIMATION_PLAYER_PATH := "AnimationPlayer" const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer" const RAGDOLL_LIMB_RADIUS := 8.0 const RAGDOLL_TORSO_RADIUS := 12.0 const RAGDOLL_HEAD_RADIUS := 100.0 ## Visual mesh colors: the rig's authored part color (gray Line2D limbs) and the ## head's filled white circle. Collision shapes are invisible in-game, so each ## ragdoll body gets a matching visible mesh (Line2D capsule / Polygon2D circle). const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488) const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE const RAGDOLL_CIRCLE_SEGMENTS := 32 ## Ragdoll body definitions, ordered parent-before-child. `node_path` is ## Skeleton2D-relative for bones and rig-root-relative for the head visual. ## `kind` is "bone" (capsule along a Bone2D) or "visual" (circle at Body/Head). const RAGDOLL_BODIES: Array[Dictionary] = [ { "key": "torso", "kind": "bone", "node_path": "Torso", "parent": "", "shape": "capsule", "radius": RAGDOLL_TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 }, { "key": "head", "kind": "visual", "node_path": "Body/Head", "parent": "torso", "shape": "circle", "radius": RAGDOLL_HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 }, { "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "parent": "left_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "parent": "right_upper_arm", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "parent": "left_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "parent": "torso", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "parent": "right_upper_leg", "shape": "capsule", "radius": RAGDOLL_LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, ] ## Ragdoll joint definitions: one PinJoint2D per non-root body, pinned at the ## child bone's origin. `limit` selects the angular-limit band (neck free, ## shoulder/hip ±160°, elbow/knee −5°..+150°, or its mirrored CCW variant). const RAGDOLL_JOINTS: Array[Dictionary] = [ { "child": "head", "parent": "torso", "pin_node_path": "Torso/Head", "limit": "neck" }, { "child": "left_upper_arm", "parent": "torso", "pin_node_path": "Torso/LeftUpperArm", "limit": "shoulder_hip" }, { "child": "left_lower_arm", "parent": "left_upper_arm", "pin_node_path": "Torso/LeftUpperArm/LeftLowerArm", "limit": "elbow_knee" }, { "child": "right_upper_arm", "parent": "torso", "pin_node_path": "Torso/RightUpperArm", "limit": "shoulder_hip" }, { "child": "right_lower_arm", "parent": "right_upper_arm", "pin_node_path": "Torso/RightUpperArm/RightLowerArm", "limit": "elbow_knee_ccw" }, { "child": "left_upper_leg", "parent": "torso", "pin_node_path": "Torso/LeftUpperLeg", "limit": "shoulder_hip" }, { "child": "left_lower_leg", "parent": "left_upper_leg", "pin_node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "elbow_knee_ccw" }, { "child": "right_upper_leg", "parent": "torso", "pin_node_path": "Torso/RightUpperLeg", "limit": "shoulder_hip" }, { "child": "right_lower_leg", "parent": "right_upper_leg", "pin_node_path": "Torso/RightUpperLeg/RightLowerLeg", "limit": "elbow_knee" }, ] # --------------------------------------------------------------------------- # Exported controls # --------------------------------------------------------------------------- ## Facing preset. Setting it overwrites the four per-joint bend values from ## PROFILE_FLAGS and reorders Body/* children. Default FORWARD. @export var facing_profile: FacingProfile = FacingProfile.FORWARD: set(value): if facing_profile == value: return facing_profile = value if _nodes_ready: _apply_profile() ## Per-joint bend direction (the actual flip_bend_direction source of truth). ## Defaults match the FORWARD profile. Individually overridable after a profile ## is applied (drifting away from the preset, matching the harness right-click). @export_group("Bend Direction") @export_enum("Normal", "Inverted") var left_arm_bend: int = BendDirection.NORMAL: set(value): left_arm_bend = value _set_joint_bend_inverted("LeftArm", value == BendDirection.INVERTED) @export_enum("Normal", "Inverted") var right_arm_bend: int = BendDirection.INVERTED: set(value): right_arm_bend = value _set_joint_bend_inverted("RightArm", value == BendDirection.INVERTED) @export_enum("Normal", "Inverted") var left_leg_bend: int = BendDirection.INVERTED: set(value): left_leg_bend = value _set_joint_bend_inverted("LeftLeg", value == BendDirection.INVERTED) @export_enum("Normal", "Inverted") var right_leg_bend: int = BendDirection.NORMAL: set(value): right_leg_bend = value _set_joint_bend_inverted("RightLeg", value == BendDirection.INVERTED) # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when the facing preset changes (after flags + z-order are applied). signal facing_profile_changed(profile: int) ## Emitted when a single joint's bend direction changes. `flipped` is the new ## flip_bend_direction value (true == inverted). signal bend_flag_changed(joint: String, flipped: bool) ## Emitted when the rig's ANIMATED/RAGDOLL state changes. `new_state` carries ## the RigState enum value. signal state_changed(new_state: int) # --------------------------------------------------------------------------- # Internal state # --------------------------------------------------------------------------- var _nodes_ready: bool = false var _skeleton: Skeleton2D = null var _body_container: Node2D = null var _bend_joint_bones: Dictionary = {} # { String : Bone2D } (lower bones) var _bend_modifications: Dictionary = {} # { String : SkeletonModification2DTwoBoneIK } # --------------------------------------------------------------------------- # Rig state (ANIMATED / RAGDOLL) # --------------------------------------------------------------------------- var state: RigState = RigState.ANIMATED var _ragdoll_root: Node2D = null var _ragdoll_bodies: Dictionary = {} # { String : RigidBody2D } var _anim_player: AnimationPlayer = null var _prev_global_pos: Vector2 = Vector2.ZERO var _prev_global_rot: float = 0.0 var _cached_linear_velocity: Vector2 = Vector2.ZERO var _cached_angular_velocity: float = 0.0 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: # Resolve all runtime node references. _skeleton = get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D if _skeleton == null: push_warning("StickmanRig: missing '%s' node in rig." % SKELETON_PATH) _body_container = get_node_or_null(NodePath(BODY_CONTAINER_PATH)) as Node2D if _body_container == null: push_warning("StickmanRig: missing '%s' node in rig." % BODY_CONTAINER_PATH) _anim_player = get_node_or_null(NodePath(ANIMATION_PLAYER_PATH)) as AnimationPlayer if _anim_player == null: push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH) _resolve_bend_modifications() # Enable the modification stack (IK solves only at runtime). if _skeleton != null: var stack: SkeletonModificationStack2D = _skeleton.modification_stack if stack != null: stack.enabled = true else: push_warning("StickmanRig: Skeleton2D has no modification_stack assigned.") # Apply the authored/default profile once: writes the four mod flags from the # current var values, reorders Body/* children, and emits the profile signal. # _nodes_ready is set first so the per-joint setters route their mod updates # + signal emissions through the live apply path (during instantiation the # setters only stored values). _nodes_ready = true _apply_profile() _prev_global_pos = global_position _prev_global_rot = global_rotation func _physics_process(delta: float) -> void: _track_momentum(delta) func _track_momentum(delta: float) -> void: if delta > 0.0: _cached_linear_velocity = (global_position - _prev_global_pos) / delta _cached_angular_velocity = wrapf(global_rotation - _prev_global_rot, -PI, PI) / delta _prev_global_pos = global_position _prev_global_rot = global_rotation # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- func set_facing_profile(profile: int) -> void: if not PROFILE_FLAGS.has(profile): push_warning("StickmanRig: unknown facing profile %d; ignored." % profile) return facing_profile = profile func get_facing_profile() -> int: return int(facing_profile) func set_joint_bend_flipped(joint: String, flipped: bool) -> void: match joint: "LeftArm": left_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL "RightArm": right_arm_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL "LeftLeg": left_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL "RightLeg": right_leg_bend = BendDirection.INVERTED if flipped else BendDirection.NORMAL _: push_warning("StickmanRig: unknown bend joint '%s'; ignored." % joint) func get_joint_bend_flipped(joint: String) -> bool: match joint: "LeftArm": return left_arm_bend == BendDirection.INVERTED "RightArm": return right_arm_bend == BendDirection.INVERTED "LeftLeg": return left_leg_bend == BendDirection.INVERTED "RightLeg": return right_leg_bend == BendDirection.INVERTED _: push_warning("StickmanRig: unknown bend joint '%s'." % joint) return false func get_bend_joints() -> Array[String]: return BEND_JOINTS func get_bend_joint_global_position(joint: String) -> Vector2: var bone := _bend_joint_bones.get(joint) as Bone2D if bone == null or not is_instance_valid(bone): push_warning("StickmanRig: unknown or missing bend joint '%s'." % joint) return Vector2.ZERO return bone.global_position func is_in_ragdoll() -> bool: return state == RigState.RAGDOLL func set_ragdoll(enabled: bool) -> void: if enabled and not is_in_ragdoll(): _enter_ragdoll() elif not enabled and is_in_ragdoll(): _exit_ragdoll() func toggle_ragdoll() -> void: set_ragdoll(not is_in_ragdoll()) ## Applies the same velocity delta to every ragdoll body via a mass-scaled ## central impulse, preserving the ragdoll's internal structure. No-op outside ## RAGDOLL mode. Used by the physics harness "Knock Up" button. func apply_ragdoll_velocity_boost(velocity: Vector2) -> void: if not is_in_ragdoll(): return for key: String in _ragdoll_bodies: var body := _ragdoll_bodies[key] as RigidBody2D if body != null and is_instance_valid(body): body.apply_central_impulse(velocity * body.mass) # --------------------------------------------------------------------------- # Internal resolution / apply # --------------------------------------------------------------------------- func _resolve_bend_modifications() -> void: _bend_joint_bones.clear() _bend_modifications.clear() if _skeleton == null: return for joint: String in BEND_JOINTS: var path: String = BEND_JOINT_BONE_PATHS[joint] var bone := _skeleton.get_node_or_null(NodePath(path)) as Bone2D if bone != null and is_instance_valid(bone): _bend_joint_bones[joint] = bone else: push_warning("StickmanRig: missing bend-joint bone '%s'." % path) var stack: SkeletonModificationStack2D = _skeleton.modification_stack if stack == null: return for i: int in stack.modification_count: var mod := stack.get_modification(i) if not (mod is SkeletonModification2DTwoBoneIK): continue var ik := mod as SkeletonModification2DTwoBoneIK for joint: String in BEND_JOINTS: if ik.joint_two_bone2d_node == NodePath(BEND_JOINT_BONE_PATHS[joint]): _bend_modifications[joint] = ik break for joint: String in BEND_JOINTS: if not _bend_modifications.has(joint): push_warning("StickmanRig: missing TwoBoneIK modification for bend joint '%s'." % joint) ## Writes the four per-joint bend vars from PROFILE_FLAGS (through their setters, ## so the live mods stay in sync), reorders Body/* children, then emits ## facing_profile_changed. func _apply_profile() -> void: var flags: Dictionary = PROFILE_FLAGS.get(facing_profile, PROFILE_FLAGS[FacingProfile.FORWARD]) left_arm_bend = BendDirection.INVERTED if bool(flags.get("LeftArm", false)) else BendDirection.NORMAL right_arm_bend = BendDirection.INVERTED if bool(flags.get("RightArm", false)) else BendDirection.NORMAL left_leg_bend = BendDirection.INVERTED if bool(flags.get("LeftLeg", false)) else BendDirection.NORMAL right_leg_bend = BendDirection.INVERTED if bool(flags.get("RightLeg", false)) else BendDirection.NORMAL _apply_body_z_order() _apply_head_flip() facing_profile_changed.emit(int(facing_profile)) func _apply_head_flip() -> void: var head := get_node_or_null("Skeleton2D/Torso/Head") as Node2D var pivot := get_node_or_null("Skeleton2D/Torso/Head/Pivot") as Node2D if pivot != null: var is_left := (facing_profile == FacingProfile.LEFT) if is_left: # Mirror local X and invert double the bone's rotation to mirror in world space pivot.scale = Vector2(-1.0, 1.0) pivot.rotation = -1.0 * head.rotation else: pivot.scale = Vector2(1.0, 1.0) pivot.rotation = 0.0 ## Per-joint setter notify: updates the resolved TwoBoneIK mod's ## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the ## setter only stored the backing value during instantiation). func _set_joint_bend_inverted(joint: String, inverted: bool) -> void: if not _nodes_ready: return var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK if mod != null: mod.flip_bend_direction = inverted bend_flag_changed.emit(joint, inverted) ## Task 2 algorithm: walk the profile's ordered part names back-to-front and ## move_child(part, count - 1) each existing part; unknown/extra children stay ## at the back; missing parts skipped silently. func _apply_body_z_order() -> void: if _body_container == null or not is_instance_valid(_body_container): return var order: Array = Z_ORDER_BY_PROFILE.get(facing_profile, Z_ORDER_BY_PROFILE[FacingProfile.FORWARD]) for part_name: String in order: var part := _body_container.get_node_or_null(NodePath(part_name)) if part != null: _body_container.move_child(part, _body_container.get_child_count() - 1) # --------------------------------------------------------------------------- # Ragdoll translation (kinematic -> physics) # --------------------------------------------------------------------------- func _enter_ragdoll() -> void: if _skeleton == null or _body_container == null: push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.") return # Freeze the kinematic puppet: disable IK, stop animation, hide visuals. if _skeleton.modification_stack != null: _skeleton.modification_stack.enabled = false if _anim_player != null and is_instance_valid(_anim_player): _anim_player.stop() _body_container.visible = false _build_ragdoll() state = RigState.RAGDOLL state_changed.emit(int(state)) func _exit_ragdoll() -> void: _destroy_ragdoll() if _body_container != null and is_instance_valid(_body_container): _body_container.visible = true if _skeleton != null and is_instance_valid(_skeleton): if _skeleton.modification_stack != null: _skeleton.modification_stack.enabled = true if _anim_player != null and is_instance_valid(_anim_player): _anim_player.stop() state = RigState.ANIMATED state_changed.emit(int(state)) func _build_ragdoll() -> void: var parent: Node = get_parent() if parent == null: parent = get_tree().current_scene if parent == null: push_warning("StickmanRig: cannot reparent ragdoll container; no parent or current scene.") return _ragdoll_root = Node2D.new() _ragdoll_root.name = RAGDOLL_CONTAINER_NAME parent.add_child(_ragdoll_root) _ragdoll_bodies.clear() for entry: Dictionary in RAGDOLL_BODIES: _build_ragdoll_body(entry) for entry: Dictionary in RAGDOLL_JOINTS: _build_ragdoll_joint(entry) var torso := _ragdoll_bodies.get("torso") as RigidBody2D if torso != null: torso.linear_velocity = _cached_linear_velocity torso.angular_velocity = _cached_angular_velocity func _build_ragdoll_body(entry: Dictionary) -> void: var key: String = entry["key"] var body := RigidBody2D.new() body.name = "Ragdoll_" + key body.mass = float(entry["mass"]) body.linear_damp = float(entry["linear_damp"]) body.angular_damp = float(entry["angular_damp"]) body.gravity_scale = 1.0 body.lock_rotation = false body.freeze = false body.collision_layer = 1 body.collision_mask = 1 var shape := CollisionShape2D.new() shape.name = "CollisionShape2D" if entry["kind"] == "visual": var visual := get_node_or_null(NodePath(entry["node_path"])) as Node2D if visual == null or not is_instance_valid(visual): push_warning("StickmanRig: missing ragdoll visual node '%s'." % entry["node_path"]) body.queue_free() return var circle := CircleShape2D.new() circle.radius = float(entry["radius"]) shape.shape = circle body.add_child(shape) body.position = visual.global_position body.rotation = 0.0 _add_ragdoll_visual_circle(body, float(entry["radius"]), RAGDOLL_HEAD_VISUAL_COLOR) else: var bone := _skeleton.get_node_or_null(NodePath(entry["node_path"])) as Bone2D if bone == null or not is_instance_valid(bone): push_warning("StickmanRig: missing ragdoll bone '%s'." % entry["node_path"]) body.queue_free() return var origin: Vector2 = bone.global_position var tip: Vector2 if key == "torso": var head_bone := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D if head_bone == null or not is_instance_valid(head_bone): push_warning("StickmanRig: missing Head bone for torso ragdoll body.") body.queue_free() return tip = head_bone.global_position else: # A Bone2D's length runs along its local +X rotated by `bone_angle` # (stored in degrees). to_global(Vector2(length, 0)) alone ignores # bone_angle, so rotate the tip vector by it to reach the real # far-end joint (which coincides with the child bone's origin). tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle))) var length: float = origin.distance_to(tip) var midpoint: Vector2 = (origin + tip) * 0.5 var capsule := CapsuleShape2D.new() capsule.height = length capsule.radius = float(entry["radius"]) shape.shape = capsule # CapsuleShape2D spans local +Y, but a Bone2D's length runs along local # +X: rotate the shape -90° so the capsule aligns with the body's +X, # which we point along the bone's origin->tip direction below. shape.rotation = -PI / 2.0 body.add_child(shape) body.position = midpoint body.rotation = (tip - origin).angle() _add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR) _ragdoll_root.add_child(body) _ragdoll_bodies[key] = body ## Visible capsule mesh (Line2D with round caps) spanning the body's local +X, ## which `_build_ragdoll_body` already aligns with the bone's origin->tip ## direction. Collision shapes never render in-game, so this is what the player ## actually sees in RAGDOLL mode. func _add_ragdoll_visual_capsule(body: RigidBody2D, length: float, radius: float, color: Color) -> void: var line := Line2D.new() line.name = "VisualCapsule" line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)]) line.width = radius * 2.0 line.default_color = color line.begin_cap_mode = Line2D.LINE_CAP_ROUND line.end_cap_mode = Line2D.LINE_CAP_ROUND line.joint_mode = Line2D.LINE_JOINT_ROUND body.add_child(line) ## Visible filled circle for the head body, matching the authored head circle. func _add_ragdoll_visual_circle(body: RigidBody2D, radius: float, color: Color) -> void: var poly := Polygon2D.new() poly.name = "VisualCircle" var points := PackedVector2Array() for i: int in RAGDOLL_CIRCLE_SEGMENTS: var angle: float = TAU * float(i) / float(RAGDOLL_CIRCLE_SEGMENTS) points.append(Vector2(cos(angle), sin(angle)) * radius) poly.polygon = points poly.color = color body.add_child(poly) func _build_ragdoll_joint(entry: Dictionary) -> void: var child_key: String = entry["child"] var parent_key: String = entry["parent"] var child_body := _ragdoll_bodies.get(child_key) as RigidBody2D var parent_body := _ragdoll_bodies.get(parent_key) as RigidBody2D if child_body == null or parent_body == null: return var pin_bone := _skeleton.get_node_or_null(NodePath(entry["pin_node_path"])) as Bone2D if pin_bone == null or not is_instance_valid(pin_bone): push_warning("StickmanRig: missing pin bone '%s' for ragdoll joint '%s'." % [entry["pin_node_path"], child_key]) return var pin := PinJoint2D.new() pin.name = "RagdollPin_" + child_key pin.position = pin_bone.global_position _ragdoll_root.add_child(pin) pin.node_a = pin.get_path_to(parent_body) pin.node_b = pin.get_path_to(child_body) pin.softness = 0.0 _apply_ragdoll_joint_limits(pin, entry["limit"]) func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void: match limit: "elbow_knee": # Fold (natural bend) toward +CW, resist hyperextension past -5°. pin.angular_limit_enabled = true pin.angular_limit_lower = -deg_to_rad(5.0) pin.angular_limit_upper = deg_to_rad(150.0) "elbow_knee_ccw": # Mirrored limb: its natural bend folds -CCW (the rig's TwoBoneIK # bend flag for this limb is inverted), so the large allowance goes # on the negative side and hyperextension is capped at +5°. pin.angular_limit_enabled = true pin.angular_limit_lower = -deg_to_rad(150.0) pin.angular_limit_upper = deg_to_rad(5.0) "shoulder_hip": pin.angular_limit_enabled = true pin.angular_limit_lower = -deg_to_rad(160.0) pin.angular_limit_upper = deg_to_rad(160.0) _: pin.angular_limit_enabled = false func _destroy_ragdoll() -> void: if _ragdoll_root != null and is_instance_valid(_ragdoll_root): _ragdoll_root.queue_free() _ragdoll_root = null _ragdoll_bodies.clear()