- Removed the old create_walk.gd script, which generated walk animations. - Introduced create_animations.gd to unify the generation of walk_left, walk_right, and stand_up animations. - Added a new SpinBox for rest timeout configuration in physics_test_harness.gd. - Enhanced StickmanRig to support automatic recovery from ragdoll state with configurable timeout. - Implemented recovery logic in StickmanRig, allowing for smooth transitions from ragdoll to animated state. - Updated animation generation logic to use new pose templates for standing and lying down positions.
885 lines
36 KiB
GDScript
885 lines
36 KiB
GDScript
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);
|
||
## RECOVERING snaps the skeleton back to the captured rest pose and tweens the
|
||
## IK targets to the standing pose before returning to ANIMATED.
|
||
enum RigState { ANIMATED, RAGDOLL, RECOVERING }
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Blend / recovery constants
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Rest detection thresholds (linear px/s, angular rad/s) for the ragdoll
|
||
## torso. 5.0 px/s (not the plan's 0.1) — a soft-pinned ragdoll micro-jitters
|
||
## around ~0.5 px/s even when fully settled, so 0.1 is never reached. A body
|
||
## that the physics engine has put to sleep also counts as at rest.
|
||
const REST_LINEAR_THRESHOLD := 5.0
|
||
const REST_ANGULAR_THRESHOLD := 0.1
|
||
|
||
## Duration of the stand-up tween (captured pose -> STAND_POSE).
|
||
const STAND_UP_DURATION := 0.8
|
||
|
||
## Extra hold after rest is detected before recovery captures the pose.
|
||
const STABILIZATION_DELAY := 0.1
|
||
|
||
## Pin softness applied to every ragdoll joint at build time.
|
||
const RAGDOLL_TARGET_SOFTNESS := 0.2
|
||
|
||
## IK-target standing positions (rig-local), matching master_rig.tscn defaults.
|
||
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 },
|
||
}
|
||
|
||
## IK-target marker node paths (rig-root relative), keyed by marker name.
|
||
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",
|
||
}
|
||
|
||
## 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)
|
||
|
||
@export_group("Ragdoll Transition")
|
||
## How long the ragdoll torso must be at rest before auto-recovery (seconds).
|
||
@export var rest_timeout: float = 2.0
|
||
|
||
## When true, a rested ragdoll automatically stands back up. When false, the
|
||
## ragdoll stays down until request_recovery() is called manually.
|
||
@export var auto_recover: bool = true
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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 _torso_bone: Bone2D = 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
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recovery state
|
||
# ---------------------------------------------------------------------------
|
||
|
||
var _rest_timer: float = 0.0
|
||
var _stabilize_timer: float = 0.0
|
||
var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (rig-local)
|
||
var _stand_up_tween: Tween = null
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|
||
|
||
if _skeleton != null:
|
||
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
|
||
if _torso_bone == null:
|
||
push_warning("StickmanRig: missing 'Torso' bone in Skeleton2D.")
|
||
|
||
_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)
|
||
_update_rest_detection(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
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-frame rest detection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## RAGDOLL rest detection: when the torso sits still long enough (and
|
||
## auto_recover is on), trigger recovery after a short stabilization delay.
|
||
func _update_rest_detection(delta: float) -> void:
|
||
if state != RigState.RAGDOLL:
|
||
return
|
||
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
|
||
if torso == null or not is_instance_valid(torso):
|
||
return
|
||
var at_rest := torso.sleeping \
|
||
or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD \
|
||
and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
|
||
if not at_rest:
|
||
_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:
|
||
return
|
||
_stabilize_timer += delta
|
||
if _stabilize_timer >= STABILIZATION_DELAY:
|
||
_start_recovery()
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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:
|
||
match state:
|
||
RigState.RAGDOLL:
|
||
return
|
||
RigState.RECOVERING:
|
||
_cancel_recovery()
|
||
_enter_ragdoll()
|
||
_:
|
||
_enter_ragdoll()
|
||
else:
|
||
if state == RigState.RAGDOLL:
|
||
_start_recovery()
|
||
# else ANIMATED / RECOVERING: no-op
|
||
|
||
|
||
func toggle_ragdoll() -> void:
|
||
set_ragdoll(not is_in_ragdoll())
|
||
|
||
|
||
## Public stand-up request. No-op unless the rig is in RAGDOLL.
|
||
func request_recovery() -> void:
|
||
if state == RigState.RAGDOLL:
|
||
_start_recovery()
|
||
|
||
|
||
## 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
|
||
# Instant handoff: stop the player without resetting it (keep_state) and
|
||
# build the ragdoll from the CURRENT solved bone positions while the IK
|
||
# stack is still enabled (disabling it first would revert the bones to the
|
||
# authored rest pose). Body/* is then hidden immediately and IK disabled —
|
||
# no crossfade, because the ragdoll is spawned at exactly the same pose, so
|
||
# a fade would only read as ghosting.
|
||
if _anim_player != null and is_instance_valid(_anim_player):
|
||
_anim_player.stop(true)
|
||
_build_ragdoll()
|
||
if is_instance_valid(_body_container):
|
||
_body_container.visible = false
|
||
_body_container.modulate.a = 1.0
|
||
if _skeleton.modification_stack != null:
|
||
_skeleton.modification_stack.enabled = false
|
||
state = RigState.RAGDOLL
|
||
state_changed.emit(int(state))
|
||
_rest_timer = 0.0
|
||
_stabilize_timer = 0.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Recovery (ragdoll -> kinematic stand-up)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
## Captures every ragdoll body's global transform into rig-local space, plus
|
||
## each capsule's half-length (from build-time metadata) so the snap can derive
|
||
## the real joint ends (hip / wrist / ankle) instead of body midpoints.
|
||
func _capture_ragdoll_pose() -> void:
|
||
_captured_pose.clear()
|
||
for key: String in _ragdoll_bodies:
|
||
var body := _ragdoll_bodies[key] as RigidBody2D
|
||
if body == null or not is_instance_valid(body):
|
||
continue
|
||
_captured_pose[key] = {
|
||
"pos": to_local(body.global_position),
|
||
"rot": body.global_rotation - global_rotation,
|
||
"half": float(body.get_meta("half_height", 0.0)),
|
||
}
|
||
|
||
|
||
func _start_recovery() -> void:
|
||
_capture_ragdoll_pose()
|
||
_destroy_ragdoll()
|
||
state = RigState.RECOVERING
|
||
state_changed.emit(int(state))
|
||
_snap_skeleton_to_pose()
|
||
_play_stand_up()
|
||
|
||
|
||
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
|
||
## clean slate.
|
||
func _cancel_recovery() -> void:
|
||
if _stand_up_tween != null and _stand_up_tween.is_valid():
|
||
_stand_up_tween.kill()
|
||
_stand_up_tween = null
|
||
|
||
|
||
## Marker-driven kinematic snap: writes the captured pose onto the 6 IK-target
|
||
## markers (NOT the Torso Bone2D, which is slaved to its marker via
|
||
## RemoteTransform2D), then re-enables IK so TwoBoneIK solves the limbs toward
|
||
## the captured end-effectors.
|
||
##
|
||
## Geometry notes: the ragdoll capsules span joint origin -> tip along their
|
||
## +X (body.rotation IS the segment direction), so the real joints are at
|
||
## center ± direction * half_height. The Torso marker rotation must also
|
||
## subtract the Torso Bone2D's `bone_angle` (bone world angle = marker rotation
|
||
## + bone_angle); using the body rotation directly would slam the whole
|
||
## skeleton -90° and lay the figure flat.
|
||
func _snap_skeleton_to_pose() -> void:
|
||
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 spine_dir := Vector2.from_angle(torso_pose.get("rot", 0.0))
|
||
var half := float(torso_pose.get("half", 0.0))
|
||
var bone_angle_rad := 0.0
|
||
if _torso_bone != null:
|
||
bone_angle_rad = deg_to_rad(_torso_bone.bone_angle)
|
||
# Hip = spine bottom end of the torso capsule.
|
||
torso_marker.position = torso_pose.get("pos", torso_marker.position) - spine_dir * half
|
||
torso_marker.rotation = torso_pose.get("rot", 0.0) - bone_angle_rad
|
||
|
||
var head_marker := _get_ik_marker("Head")
|
||
if head_marker != null:
|
||
var head_pose: Dictionary = _captured_pose.get("head", {})
|
||
if 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")
|
||
|
||
# Show the kinematic puppet first so it appears already in the captured
|
||
# pose, then re-enable IK to solve toward the end-effector markers.
|
||
if _body_container != null and is_instance_valid(_body_container):
|
||
_body_container.visible = true
|
||
_body_container.modulate.a = 1.0
|
||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||
_skeleton.modification_stack.enabled = true
|
||
|
||
|
||
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
|
||
var marker := _get_ik_marker(marker_name)
|
||
if marker == null:
|
||
return
|
||
var pose: Dictionary = _captured_pose.get(body_key, {})
|
||
if pose.is_empty():
|
||
return
|
||
# Far end (wrist / ankle) = body center + segment direction * half.
|
||
var dir := Vector2.from_angle(pose.get("rot", 0.0))
|
||
var half := float(pose.get("half", 0.0))
|
||
marker.position = pose.get("pos", marker.position) + dir * half
|
||
|
||
|
||
func _get_ik_marker(name: String) -> Marker2D:
|
||
var path: String = IK_TARGET_PATHS.get(name, "")
|
||
if path.is_empty():
|
||
return null
|
||
return get_node_or_null(NodePath(path)) as Marker2D
|
||
|
||
|
||
## Stand-up: tweens the 6 IK markers from the captured pose to STAND_POSE
|
||
## (sine ease-in-out). No baked animation — a fixed first keyframe can never
|
||
## match an arbitrary ragdoll rest pose, so the tween starts from wherever the
|
||
## snap left the markers.
|
||
func _play_stand_up() -> void:
|
||
_stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION)
|
||
if _stand_up_tween != null:
|
||
_stand_up_tween.finished.connect(_on_stand_up_finished)
|
||
|
||
|
||
## Tweens the 6 IK markers from their current (captured) values to the target
|
||
## pose over `duration` (sine ease-in-out), all in parallel.
|
||
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
|
||
var tween := create_tween()
|
||
tween.set_parallel(true)
|
||
tween.set_trans(Tween.TRANS_SINE)
|
||
tween.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
|
||
|
||
|
||
## Stand-up tween complete: settle into ANIMATED.
|
||
func _on_stand_up_finished() -> void:
|
||
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
|
||
_skeleton.modification_stack.enabled = true
|
||
if _body_container != null and is_instance_valid(_body_container):
|
||
_body_container.visible = true
|
||
_body_container.modulate.a = 1.0
|
||
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
|
||
body.set_meta("half_height", 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()
|
||
# Half the capsule's length along the body's +X — lets recovery derive
|
||
# the joint ends (hip/wrist/ankle) from the body center at capture time.
|
||
body.set_meta("half_height", length * 0.5)
|
||
_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 = RAGDOLL_TARGET_SOFTNESS
|
||
_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):
|
||
# Retire the name immediately so a same-frame _build_ragdoll (e.g.
|
||
# set_ragdoll(true) during RECOVERING) does not get its fresh container
|
||
# auto-renamed by Godot's sibling-name de-duplication while the old one
|
||
# is still awaiting its deferred queue_free().
|
||
_ragdoll_root.name = RAGDOLL_CONTAINER_NAME + "_retired"
|
||
_ragdoll_root.queue_free()
|
||
_ragdoll_root = null
|
||
_ragdoll_bodies.clear()
|