Implement Ragdoll Cleanup: Fix recovery position offset, visual identity, and profile persistence

- Added a new implementation plan for addressing ragdoll issues in stickman character.
- Resolved recovery position offset by replacing torso-radius ground calculation with a downward raycast.
- Enhanced ragdoll visual identity by ensuring ragdoll bodies inherit colors and shapes from original Body nodes.
- Implemented profile persistence in ragdoll mode to respect joint bending based on facing profile.
- Updated `stickman_rig.gd` to include necessary changes for the above fixes.
- Created architecture specification detailing the implementation and acceptance criteria for each fix.
This commit is contained in:
2026-09-09 23:22:18 -04:00
parent 80595a0273
commit 699b623be3
3 changed files with 1243 additions and 62 deletions
+199
View File
@@ -0,0 +1,199 @@
# Ragdoll Cleanup — Implementation Plan
## 1. Overview
This plan addresses three specific issues with the stickman's ragdoll state:
| # | Issue | Impact |
| --- | ---------------------------- | -------------------------------------------------------------------- |
| 1 | **Recovery Position Offset** | Stickman floats above the terrain when recovering from ragdoll. |
| 2 | **Visual Identity Loss** | Custom colors/shapes disappear; replaced by gray bones. |
| 3 | **Profile Persistence** | Joint bending doesn't respect LEFT/RIGHT/FORWARD profile in ragdoll. |
Each issue has a clear fix and verification steps.
---
## 2. Issue 1: Recovery Position Offset (Floating)
### 2.1. The Problem
When the stickman recovers from ragdoll, it appears floating above the terrain at the landing position, rather than standing with feet on the ground.
**Root Cause:** `_reanchor_root_to_landing()` calculates the ground contact point using `_captured_ground_y = torso.center.y + RAGDOLL_TORSO_RADIUS`. This assumes the torso is lying flat on its side. If the ragdoll's torso is rotated (as it often is after a fall), the calculated ground point is incorrect.
### 2.2. The Fix
Replace the torso-capsule ground calculation with a **downward raycast** from the ragdoll's center to find the actual terrain surface.
**Implementation:**
```gdscript
# In _start_recovery() before destroying the ragdoll:
func _get_ground_y_at_position(world_pos: Vector2) -> float:
var space := get_world_2d().direct_space_state
var query := PhysicsRayQueryParameters2D.create(
world_pos + Vector2(0, -50), # Start slightly above
world_pos + Vector2(0, 2000) # Downward
)
query.collision_mask = 1 # Terrain layer
var result := space.intersect_ray(query)
if result:
return result.position.y
return world_pos.y # Fallback to current Y
```
Then use this to position the standing figure's feet:
```gdscript
# In _reanchor_root_to_landing():
var ground_y := _get_ground_y_at_position(_captured_landing_center)
var feet := Vector2(_captured_landing_center.x, ground_y)
var new_root := feet + FOOT_OFFSET
global_position = new_root
```
### 2.3. Acceptance Criteria
- [ ] Stickman recovers to a standing position with feet touching the terrain surface.
- [ ] Works on flat ground.
- [ ] Works on slopes and uneven terrain.
- [ ] Works on ramp edges.
- [ ] No floating or clipping into terrain.
## 3. Issue 2: Ragdoll Visual Identity
### 3.1. The Problem
When entering ragdoll mode, the stickman's custom colors and shapes are hidden, replaced by gray capsule "bone guide" visuals.
Root Cause: \_enter_ragdoll() hides Body/\* visuals and spawns plain RigidBody2D bodies with default capsule colors. The gray appearance is the default capsule color.
3.2. The Fix
Ragdoll bodies should inherit the visual properties (colors, shapes) from the Body/\* nodes.
**Implementation:**
Read the geometry and color from each Body/\* part node (Line2D or Polygon2D).
Create matching visual children on each ragdoll rigidbody.
The ragdoll's collision shapes remain simple capsules for physics; visual shapes are cosmetic overlays.
Mapping:
```
Body Node Ragdoll Body Visual Child
Body/Head Head RigidBody CircleShape2D (color from visual)
Body/Body Torso RigidBody CapsuleShape2D (color from visual)
Body/LeftUpperArm LeftUpperArm RigidBody CapsuleShape2D (color from visual)
etc. etc. etc.
```
**Important:** The visual shapes are positioned/rotated to match the ragdoll body's orientation. They are cosmetic only — physics uses the capsule collision shapes underneath.
### 3.3. Acceptance Criteria
- [ ] Ragdoll bodies match the stickman's custom colors and shapes.
- [ ] No gray "bone guide" visuals appear.
- [ ] Visuals transition smoothly between ANIMATED and RAGDOLL.
- [ ] Visuals stay consistent when exiting ragdoll.
## 4. Issue 3: Profile Persistence in Ragdoll
### 4.1. The Problem
The stickman's facing profile (LEFT/RIGHT/FORWARD) bone bending does not persist into ragdoll mode. Joints bend unrealistically when ragdolling.
Root Cause: The ragdoll's PinJoint2D angular limits are set once at build time using a generic rule. They don't account for the current facing_profile.
### 4.2. The Fix
The ragdoll build should read the current facing_profile and apply profile-specific joint limits.
**Implementation:**
When building the ragdoll (\_build_ragdoll()), pass the current facing_profile to \_apply_ragdoll_joint_limits():
**FORWARD:** Use the existing default (elbows/knees fold toward +CW).
**LEFT:** Mirror the joint limits (swap the elbow/knee fold direction).
**RIGHT:** Use the same as FORWARD (or mirror appropriately).
**Using PROFILE_FLAGS:**
The existing PROFILE_FLAGS constants already map facing profiles to per-joint flip_bend_direction flags. Use these to determine joint limit mirroring:
````
Joint FORWARD LEFT RIGHT
LeftArm Normal Normal Inverted
RightArm Inverted Normal Normal
LeftLeg Inverted Inverted Normal
RightLeg Normal Inverted Inverted
``
The flip_bend_direction flag determines which way the joint naturally bends. This should be applied to the ragdoll's PinJoint2D angular limits.
Example for elbow/knee joints:
```gdscript
# If flipped = true, the joint bends toward -CW instead of +CW
if flipped:
joint.angular_limit_lower = -150.0
joint.angular_limit_upper = 5.0
else:
joint.angular_limit_lower = -5.0
joint.angular_limit_upper = 150.0
````
### 4.3. Acceptance Criteria
- [ ] LEFT profile ragdoll joints bend the correct way.
- [ ] RIGHT profile ragdoll joints bend the correct way.
- [ ] FORWARD profile ragdoll joints bend the correct way.
- [ ] Profile persists after recovery.
- [ ] Joints do not hyperextend or bend backwards.
## 5. Implementation Order
| Step | Task | Dependencies |
| ---- | ----------------------------------------------------------------------------------------- | ------------ |
| 1 | Fix Recovery Position — Replace torso-capsule ground calculation with downward raycast. | None |
| 2 | Fix Ragdoll Visuals — Ragdoll bodies inherit Body/\* geometry/color as cosmetic children. | None |
| 3 | Fix Profile Persistence — Ragdoll joints respect facing_profile via PROFILE_FLAGS. | None |
| 4 | Integration Test — Test all three fixes together in the Sandbox Stage. | Steps 1-3 |
## 6. File Changes Summary
| File | Changes |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| scripts/stickman_rig.gd | Fix \_reanchor_root_to_landing() with raycast; \_build_ragdoll() reads facing_profile; \_apply_ragdoll_joint_limits() uses profile flags for mirroring; \_enter_ragdoll() preserves visual geometry. |
## 7. Full Acceptance Criteria
### 7.1. Recovery Position
- [ ] Stickman recovers to a standing position with feet touching the terrain surface.
- [ ] Works on flat ground, slopes, and uneven terrain.
- [ ] No floating or clipping into terrain.
### 7.2. Ragdoll Visuals
- [ ] Ragdoll bodies match the stickman's custom colors and shapes.
- [ ] No gray "bone guide" visuals appear.
- [ ] Visuals transition smoothly between ANIMATED and RAGDOLL.
- [ ] Visuals stay consistent when exiting ragdoll.
### 7.3. Profile Persistence
- [ ] LEFT profile ragdoll joints bend the correct way.
- [ ] RIGHT profile ragdoll joints bend the correct way.
- [ ] FORWARD profile ragdoll joints bend the correct way.
- [ ] Profile persists after recovery.
- [ ] Joints do not hyperextend or bend backwards.
### 7.4. Backward Compatibility
- [ ] Existing ragdoll behavior remains intact.
- [ ] Existing profiles work as before.
- [ ] No new errors or warnings in the console.
+632 -61
View File
@@ -54,7 +54,7 @@ const BEND_JOINT_BONE_PATHS: Dictionary = {
## 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.LEFT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.RIGHT: { "LeftArm": true, "RightArm": true, "LeftLeg": false, "RightLeg": false },
FacingProfile.FORWARD: { "LeftArm": false, "RightArm": true, "LeftLeg": true, "RightLeg": false },
}
@@ -108,6 +108,35 @@ const RAGDOLL_VISUAL_COLOR := Color(0.445488, 0.445488, 0.445488)
const RAGDOLL_HEAD_VISUAL_COLOR := Color.WHITE
const RAGDOLL_CIRCLE_SEGMENTS := 32
## Ground probe (recovery): the ray fans 7 X-offsets around the landing X,
## starts this far above the ragdoll's topmost point and ends this far below
## its bottommost point.
const GROUND_PROBE_START_OFFSET := 50.0
const GROUND_PROBE_END_OFFSET := 1500.0
## A hit is accepted only inside this band around the ragdoll's top/bottom
## extent (rejects overhang hits above and gap hits far below).
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]
## Upper + lower Bone2D paths (Skeleton2D-relative) per bend joint. Used to
## drive the limb bones directly during recovery while the TwoBoneIK mods are
## disabled (the IK re-solve would otherwise snap limbs to the profile's
## preferred bend solution instead of the captured ragdoll pose).
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"],
}
## Ragdoll body keys (upper, lower) per bend joint.
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"],
}
# ---------------------------------------------------------------------------
# Blend / recovery constants
# ---------------------------------------------------------------------------
@@ -169,8 +198,9 @@ const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
## Debug gate for the Phase 3a walk/runner trace. Ship OFF.
const DEBUG_WALK := false
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
const DEBUG_RECOVERY := false
## Debug gate for the ragdoll-recovery re-solve trace. TEMPORARILY true for
## the Round 4 transition debugging — flip back to false once verified.
const DEBUG_RECOVERY := true
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void:
@@ -185,16 +215,16 @@ const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
## 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 },
{ "key": "torso", "kind": "bone", "node_path": "Torso", "visual_path": "Body/Body", "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", "visual_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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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", "visual_path": "Body/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
@@ -322,6 +352,13 @@ var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (
var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
var _stand_up_tween: Tween = null
var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } }
var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up
var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recovery's END target
var _debug_recover_time: float = -1.0 # throttle accumulator for the recovery visual prints
var _probe_top_y: float = 0.0 # ground-probe context retained for the ankle clamp
var _probe_bottom_y: float = 0.0
var _probe_exclude: Array[RID] = []
# ---------------------------------------------------------------------------
# Director / navigation state (Phase 3a)
@@ -385,6 +422,7 @@ func _ready() -> void:
push_warning("StickmanRig: missing '%s' node in rig." % ANIMATION_PLAYER_PATH)
_resolve_bend_modifications()
_resolve_limb_bones()
# Enable the modification stack (IK solves only at runtime).
if _skeleton != null:
@@ -433,17 +471,18 @@ func _physics_process(delta: float) -> void:
_update_speech(delta)
_update_runner(delta)
_pin_mirrored_head_rotation()
_debug_recovery_visuals(delta)
## Re-asserts the head bone's FORWARD canonical aim rotation each frame while
## facing LEFT with the kinematic puppet live (ANIMATED or RECOVERING). The
## LookAt modification is disabled in that state (_apply_head_lookat_mirror_mode)
## so nothing else rewrites the bone; the per-frame pin covers recovery, where
## the IK stack is re-enabled mid-tween.
## facing LEFT and ANIMATED. The LookAt modification is disabled in that state
## (_apply_head_lookat_mirror_mode) so nothing else rewrites the bone. During
## RECOVERING the pin is skipped: the stand-up tween drives the head bone
## itself, and the LookAt is disabled, so a per-frame pin would fight the tween.
func _pin_mirrored_head_rotation() -> void:
if facing_profile != FacingProfile.LEFT or state == RigState.RAGDOLL:
if facing_profile != FacingProfile.LEFT or state != RigState.ANIMATED:
return
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
@@ -592,6 +631,9 @@ func snap_to_standing() -> void:
# Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
_set_limb_mods_enabled(true)
_restore_head_lookat_state()
_recovery_bones.clear()
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
@@ -663,6 +705,23 @@ func _resolve_bend_modifications() -> void:
push_warning("StickmanRig: missing TwoBoneIK modification for bend joint '%s'." % joint)
## Resolves the 8 limb Bone2D nodes (upper + lower per bend joint). Called from
## _ready() so recovery can drive the bones directly while the TwoBoneIK mods
## are disabled.
func _resolve_limb_bones() -> void:
_limb_bones.clear()
if _skeleton == null:
return
for joint: String in LIMB_BONE_PATHS:
var paths: Array = LIMB_BONE_PATHS[joint]
var upper := _skeleton.get_node_or_null(NodePath(paths[0])) as Bone2D
var lower := _skeleton.get_node_or_null(NodePath(paths[1])) as Bone2D
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
push_warning("StickmanRig: missing limb bones for bend joint '%s'." % joint)
continue
_limb_bones[joint] = { "upper": upper, "lower": lower }
## 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.
@@ -735,10 +794,80 @@ func _apply_body_z_order() -> void:
# Ragdoll translation (kinematic -> physics)
# ---------------------------------------------------------------------------
## Captures the kinematic puppet's current state at the moment ragdoll is
## entered: the 6 IK-target marker values, the 8 limb bone rotations, the head
## bone rotation, the facing profile, the 4 live bend flags, and the head
## LookAt mod's enabled state. This snapshot is the recovery tween's END target
## — the figure returns to the exact pose it had just before the fall (which
## may be a mid-walk pose); the animation is not resumed.
func _capture_pre_ragdoll_state() -> void:
_pre_ragdoll_state.clear()
var markers := {}
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 limb_rotations := {}
for joint: String in _limb_bones:
var pair: Dictionary = _limb_bones[joint]
var upper: Bone2D = pair.get("upper")
var lower: Bone2D = pair.get("lower")
limb_rotations[joint] = {
"upper": upper.rotation if upper != null and is_instance_valid(upper) else 0.0,
"lower": lower.rotation if lower != null and is_instance_valid(lower) else 0.0,
}
_pre_ragdoll_state["limb_rotations"] = limb_rotations
_pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null and is_instance_valid(_head_bone) else 0.0
_pre_ragdoll_state["facing_profile"] = int(facing_profile)
var bends := {}
for joint: String in BEND_JOINTS:
bends[joint] = 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 and is_instance_valid(_head_look_at) else false
# Per-leg knee side (sign of the hip->ankle vs hip->knee cross) at entry,
# used at the snap to set the leg IK bend flags so the re-solve keeps the
# captured knee side instead of flipping to the mirrored solution.
var leg_sides := {}
for leg_joint: String in ["LeftLeg", "RightLeg"]:
var pair: Dictionary = _limb_bones.get(leg_joint, {})
var upper: Bone2D = pair.get("upper")
var lower: Bone2D = pair.get("lower")
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
continue
var marker_name: String = "Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg"
var ankle_marker := _get_ik_marker(marker_name)
if ankle_marker == null:
continue
var hip := upper.global_position
var knee := lower.global_position
var ankle := ankle_marker.global_position
var side := (ankle - hip).cross(knee - hip)
leg_sides[leg_joint] = { "side": 1.0 if side >= 0.0 else -1.0, "flag": get_joint_bend_flipped(leg_joint) }
_pre_ragdoll_state["leg_sides"] = leg_sides
# Chain-space offsets that map captured ragdoll angles onto the node chains:
# the torso marker's entry rotation relative to the entry spine direction,
# and the head bone's entry chain relative to the head body's entry angle
# (the head ragdoll body spawns with rotation 0). Captured here so the
# recovery snap needs no authored geometry constants, and the mirrored
# root's ambiguous global_rotation decomposition (+/-PI) cancels out.
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 and is_instance_valid(_head_bone) and is_instance_valid(_torso_bone):
spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - 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(-global_rotation) - head_entry_chain
func _enter_ragdoll() -> void:
if _skeleton == null or _body_container == null:
push_warning("StickmanRig: cannot enter ragdoll; missing rig nodes.")
return
_capture_pre_ragdoll_state()
_cancel_walking()
_ragdoll_at_rest = false
# Instant handoff: stop the player without resetting it (keep_state) and
@@ -772,21 +901,77 @@ func _capture_ragdoll_pose() -> void:
_captured_pose.clear()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
# While capturing, also compute the ragdoll's full top/bottom extent and
# collect every body's RID so the ground probe can raycast the terrain under
# the fallen figure while excluding the ragdoll's own collision shapes.
var top_y := INF
var bottom_y := -INF
var exclude_rids: Array[RID] = []
for key: String in _ragdoll_bodies:
var body := _ragdoll_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": to_local(body.global_position),
"rot": body.global_rotation - global_rotation,
"half": float(body.get_meta("half_height", 0.0)),
"half": half,
}
# Record the ragdoll torso's world center + its ground-contact line so
# recovery can re-anchor the root's feet onto wherever the ragdoll landed.
if DEBUG_RECOVERY:
print("[ragdoll-end] %s world_pos=%s world_rot=%.2fdeg local_pos=%s rel_rot=%.2fdeg" % [
key, str(body.global_position), rad_to_deg(body.global_rotation),
str(_captured_pose[key]["pos"]), rad_to_deg(float(_captured_pose[key]["rot"]))])
# Record the ragdoll torso's world center + probe the terrain surface under
# the fallen figure so recovery can re-anchor the root's feet onto wherever
# the ragdoll actually landed (flat ground, slopes and ramp edges included).
var torso := _ragdoll_bodies.get("torso") as RigidBody2D
if torso != null and is_instance_valid(torso):
_captured_landing_center = torso.global_position
_captured_ground_y = torso.global_position.y + RAGDOLL_TORSO_RADIUS
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)
if DEBUG_RECOVERY:
print("[ragdoll-end] landing_center=%s ground_y=%.1f" % [str(_captured_landing_center), _captured_ground_y])
# Retain the probe context so the ankle clamp in _snap_skeleton_to_pose()
# can re-probe the terrain at each ankle's X using the same extents and
# exclusion set (without re-running the capture loop).
_probe_top_y = top_y
_probe_bottom_y = bottom_y
_probe_exclude = exclude_rids
## Fans downward raycasts around `world_x` to find the terrain surface under the
## landed ragdoll. Returns the accepted hit whose y is closest to `bottom_y`, or
## `bottom_y` when no acceptable hit is found. `exclude` carries the ragdoll
## bodies' RIDs so the probe ignores its own collision shapes.
func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
var best_y := bottom_y
var best_dist := INF
var space_state := get_world_2d().direct_space_state
if space_state == null:
return best_y
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.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = exclude
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var hit_position: Vector2 = hit.get("position", Vector2.ZERO)
if hit_position.y < top_y - GROUND_PROBE_TOLERANCE or hit_position.y > bottom_y + GROUND_PROBE_TOLERANCE:
continue
var dist := absf(hit_position.y - bottom_y)
if dist < best_dist:
best_dist = dist
best_y = hit_position.y
return best_y
func _start_recovery() -> void:
@@ -814,11 +999,15 @@ func _reanchor_root_to_landing() -> void:
var new_root := feet_world + FOOT_OFFSET
global_position = new_root
# Re-base the captured (rig-local) pose onto the re-anchored root so the
# snap reproduces the ragdoll's world pose, not the pre-ragdoll one.
# snap reproduces the ragdoll's world pose, not the pre-ragdoll one. The
# re-base must be mirror-aware: a world-space delta maps into rig-local
# space through the root basis's inverse (the LEFT X-mirror negates X;
# adding the raw shift would offset every position by twice the distance).
var shift := old_root - new_root
var local_shift := to_local(shift) - to_local(Vector2.ZERO)
for key: String in _captured_pose:
var entry: Dictionary = _captured_pose[key]
entry["pos"] = entry.get("pos", Vector2.ZERO) + shift
entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
@@ -882,30 +1071,160 @@ func _recovery_dbg() -> void:
])
## Marker-driven kinematic snap: writes the captured pose onto the 6 IK-target
## Prints the rendered world rotations of the 10 Body/* visual part nodes plus
## the 4 limb bone world rotations, throttled to every 0.25 s while RECOVERING
## (DEBUG_RECOVERY only). Used to pinpoint where a limb appears to spin.
func _debug_recovery_visuals(delta: float) -> void:
if not DEBUG_RECOVERY:
return
if state != RigState.RECOVERING:
_debug_recover_time = -1.0
return
if _debug_recover_time < 0.0:
_debug_recover_time = 0.0
_debug_recover_time += delta
if _debug_recover_time < 0.25:
return
_debug_recover_time = 0.0
if _body_container == null or not is_instance_valid(_body_container):
return
var parts: Array[String] = ["Body", "Head", "LeftUpperArm", "LeftLowerArm", "RightUpperArm", "RightLowerArm", "LeftUpperLeg", "LeftLowerLeg", "RightUpperLeg", "RightLowerLeg"]
for p: String in parts:
var node := _body_container.get_node_or_null(NodePath(p)) as Node2D
if node != null and is_instance_valid(node):
print("[recover-vis] %s rot=%.2fdeg" % [p, rad_to_deg(node.global_rotation)])
for joint: String in LIMB_BONE_PATHS:
var pair: Dictionary = _limb_bones.get(joint, {})
for part: String in ["upper", "lower"]:
var bone: Bone2D = pair.get(part)
if bone != null and is_instance_valid(bone):
print("[recover-bone] %s %s local=%.2fdeg global=%.2fdeg" % [joint, part, rad_to_deg(bone.rotation), rad_to_deg(bone.global_rotation)])
## Converts a captured relative angle (ragdoll body world rotation minus the
## rig root's global_rotation) into the skeleton's chain-space angle. Under the
## LEFT whole-rig X-mirror the root's global_rotation decomposes as (PI - phi),
## so captured relative angles come out negated: chain = -captured.
func _chain_angle_from_captured(captured_rel: float) -> float:
if facing_profile == FacingProfile.LEFT:
return -captured_rel
return captured_rel
## Wraps `value` to within +/-PI of `target` (shortest-rotation path) so a bone
## rotation tween never spins the long way around. The wrapped value renders
## identically to the raw one (2PI-periodic).
func _wrap_toward(value: float, target: float) -> float:
return target + wrapf(value - target, -PI, PI)
## Returns the tween target for a HINGE joint (elbows/knees only) so it bends
## the natural way, evaluated in the joint's RENDERED space (the value space is
## offset by `bone_angle_lower - bone_angle_upper` — e.g. -90deg for the legs).
## The shortest rendered path is used unless it would cross the +/-PI
## (fully-backward) boundary (opposite signs with |a| + |b| > PI) — a joint
## that fell bent backwards must straighten through 0 instead of folding
## further backwards. The returned target differs from `target` only by whole
## turns.
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 d := wrapf(b - a, -PI, PI)
if a * b < 0.0 and absf(a) + absf(b) > PI:
d += TAU if d < 0.0 else -TAU
return current + d
## Enables/disables the 4 TwoBoneIK mods. Disabled during RECOVERING so the IK
## re-solve cannot overwrite the directly-driven limb bones; re-enabled when
## the stand-up completes (or on snap_to_standing).
func _set_limb_mods_enabled(enabled: bool) -> void:
for joint: String in BEND_JOINTS:
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if mod != null and is_instance_valid(mod):
mod.enabled = enabled
## Restores the head LookAt mod's enabled state to what it was captured as
## before ragdoll (fallback: disabled).
func _restore_head_lookat_state() -> void:
if _head_look_at == null or not is_instance_valid(_head_look_at):
return
_head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false))
## Kinematic snap: writes the captured ragdoll 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.
## RemoteTransform2D), drives the ARMS' limb Bone2D rotations directly from the
## captured body angles, and sets the head bone from the head body. The two arm
## TwoBoneIK mods and the head LookAt are disabled for the snap so the re-solve
## cannot overwrite the directly-driven bones. The two LEG TwoBoneIK mods are
## instead ENABLED so the legs solve toward their ankle markers — the ankles
## are clamped up onto the terrain surface (via a ground probe at each ankle's
## X) and then tween along the ground to the pre-ragdoll ankle positions while
## the torso marker rises, so the feet never sink below the ground. Each leg
## mod's bend side is matched to the captured knee side so the re-solve cannot
## flip a knee. The stand-up tween then rotates the markers + arms + head to
## the PRE-ragdoll captured state and re-enables the arm mods.
##
## 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.
## center ± direction * half_height. All derived joint points go through
## to_local(to_global(pos) + world_dir * half) so the LEFT whole-rig X-mirror
## and any rig root rotation are handled by the transform math itself. Rotations
## use entry-captured offsets instead of authored constants: the torso marker
## rotation θ_t is the captured spine chain minus `torso_chain_offset` (which
## absorbs the bone_angle / driver conventions), the limbs parent on θ_t, and
## the head bone is the captured head chain minus `head_chain_offset` (the head
## ragdoll body's baked entry chain) minus θ_t. Every snapped rotation is
## normalized into (-PI, PI] because Godot's Skeleton2D re-wraps bone rotations
## internally, which would otherwise corrupt the tween's captured START.
##
## This function ALSO computes and stores the per-bone tween targets in
## `_recovery_bones` (the torso marker, the 4 arm limb bones, and the head
## bone). Shoulders (upper arm bones) and the head take the shortest value
## path; elbows (lower arm bones) use `_natural_joint_target` so a joint that
## fell bent backwards straightens through 0 instead of folding further
## backwards through +/-PI. The legs are excluded from the bone tween — they
## are IK-driven toward their tweening ankle markers. `_play_stand_up()` then
## only starts the tween from those stored targets.
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 pre_torso_rot := 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_chain := 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 spine_dir := Vector2.from_angle(torso_pose.get("rot", 0.0))
var torso_rel := float(torso_pose.get("rot", 0.0))
# World-space spine direction: the captured angle is relative to the
# root's global_rotation, so add it back to get the true world angle.
var spine_dir := Vector2.from_angle(torso_rel + global_rotation)
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
# Hip = spine bottom end of the torso capsule, converted through
# to_local(to_global(...)) so the LEFT X-mirror and any rig root
# rotation are handled by the transform math itself.
var center_world := to_global(torso_pose.get("pos", Vector2.ZERO))
torso_marker.position = to_local(center_world - spine_dir * half)
torso_chain = _chain_angle_from_captured(torso_rel)
# The torso bone's node rotation = the captured spine chain minus
# the entry-captured offset (the bone_angle / driver conventions
# are baked into the offset, so no authored constants are needed).
var raw_rot := torso_chain - torso_chain_offset
pre_torso_rot = float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rot))
theta_t = _wrap_toward(raw_rot, pre_torso_rot)
theta_t = wrapf(theta_t, -PI, PI)
torso_marker.rotation = theta_t
if DEBUG_RECOVERY:
print("[recover-start] torso rel=%.2fdeg chain=%.2fdeg raw=%.2fdeg pre=%.2fdeg final=%.2fdeg hip_local=%s" % [
rad_to_deg(torso_rel), rad_to_deg(torso_chain), rad_to_deg(raw_rot),
rad_to_deg(pre_torso_rot), rad_to_deg(torso_marker.rotation), str(torso_marker.position)])
var head_marker := _get_ik_marker("Head")
if head_marker != null:
@@ -918,12 +1237,171 @@ func _snap_skeleton_to_pose() -> void:
_set_marker_from_body("Left_Leg", "left_lower_leg")
_set_marker_from_body("Right_Leg", "right_lower_leg")
# Clamp the ankle markers up onto the terrain surface so the feet never
# sink below the ground during the stand-up (the leg IK follows them).
for leg_key: String in ["Left_Leg", "Right_Leg"]:
var leg_marker := _get_ik_marker(leg_key)
if leg_marker == null:
continue
var body_key: String = "left_lower_leg" if leg_key == "Left_Leg" else "right_lower_leg"
if _captured_pose.get(body_key, {}).is_empty():
continue
var ankle_world := to_global(leg_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
leg_marker.position = to_local(ankle_world)
# Drive the limb bones directly from the captured body angles: the ragdoll
# body angle IS the bone DIRECTION, while the Body/* visuals follow the bone
# NODE rotation, so the bone's own bone_angle is subtracted. The parent
# chain is the torso marker's snapped node rotation θ_t (the captured spine
# chain minus the entry-captured torso_chain_offset). The snapped rotations
# are normalized into (-PI, PI] (Godot re-wraps bone rotations internally);
# upper bones take the shortest path and lower bones use
# `_natural_joint_target` (through straight when bent backwards).
if not _captured_pose.get("torso", {}).is_empty():
for joint: String in LIMB_BONE_PATHS:
var pair: Dictionary = _limb_bones.get(joint, {})
var upper: Bone2D = pair.get("upper")
var lower: Bone2D = pair.get("lower")
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
continue
var body_keys: Array = LIMB_BODY_KEYS[joint]
var upper_pose: Dictionary = _captured_pose.get(body_keys[0], {})
var lower_pose: Dictionary = _captured_pose.get(body_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, {})
var parent_chain := theta_t
upper.rotation = _wrap_toward(
upper_chain - parent_chain - deg_to_rad(upper.bone_angle),
float(pre_pair.get("upper", upper.rotation)))
# The lower bone's node parent chain does NOT include the upper
# bone's bone_angle (bone_angle only appears in the ragdoll body
# direction), so it is added back before subtracting the lower's.
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)))
if DEBUG_RECOVERY:
print("[recover-start] %s upper_chain=%.2fdeg lower_chain=%.2fdeg rot_u=%.2fdeg rot_l=%.2fdeg pre_u=%.2fdeg pre_l=%.2fdeg" % [
joint, rad_to_deg(upper_chain), rad_to_deg(lower_chain),
rad_to_deg(upper.rotation), rad_to_deg(lower.rotation),
rad_to_deg(float(pre_pair.get("upper", upper.rotation))),
rad_to_deg(float(pre_pair.get("lower", lower.rotation)))])
# Normalize the snapped rotations into (-PI, PI]: Godot's Skeleton2D
# re-wraps bone rotations on its next internal process, which would
# otherwise change the tween's captured START and send it the long
# way around. With the start normalized, the wrap is a no-op.
upper.rotation = wrapf(upper.rotation, -PI, PI)
lower.rotation = wrapf(lower.rotation, -PI, PI)
if joint == "LeftLeg" or joint == "RightLeg":
# Legs are IK-driven during the stand-up (the ankles follow the
# ground-clamped markers then tween to the pre positions), so
# they get NO direct bone tween — but they ARE snapped to the
# captured pose above so the first recovery frame matches the
# ragdoll and the knee-side matching sees the true fallen pose.
continue
# Shoulders/hips (upper bones) take the shortest path; elbows/knees
# (lower bones) must pass through straight when the ragdoll bent
# them backwards.
var pre_u := float(pre_pair.get("upper", upper.rotation))
var pre_l := float(pre_pair.get("lower", lower.rotation))
var target_u := upper.rotation + wrapf(pre_u - upper.rotation, -PI, PI)
# The joint's rendered bend is offset by bone_angle_lower -
# bone_angle_upper (e.g. -90deg for the legs): evaluate the natural
# rule in that rendered space so the knee unfolds through straight
# instead of being sent the long way around.
var target_l := _natural_joint_target(lower.rotation, pre_l, deg_to_rad(lower.bone_angle - upper.bone_angle))
_recovery_bones.append({ "bone": upper, "target": target_u })
_recovery_bones.append({ "bone": lower, "target": target_l })
if DEBUG_RECOVERY:
print("[stand-up] %s %.2fdeg -> %.2fdeg (sweep %.2fdeg)" % [
upper.name, rad_to_deg(upper.rotation), rad_to_deg(target_u), rad_to_deg(target_u - upper.rotation)])
print("[stand-up] %s %.2fdeg -> %.2fdeg (sweep %.2fdeg)" % [
lower.name, rad_to_deg(lower.rotation), rad_to_deg(target_l), rad_to_deg(target_l - lower.rotation)])
# The head ragdoll body is a "visual" body spawned with rotation 0, so its
# cloned visual carries the entry head chain as a baked offset — captured
# at entry and subtracted back here.
if not _captured_pose.get("head", {}).is_empty() and _head_bone != null and is_instance_valid(_head_bone):
var head_rel := float((_captured_pose["head"] as Dictionary).get("rot", 0.0))
var raw_head := _chain_angle_from_captured(head_rel) - head_chain_offset - theta_t
_head_bone.rotation = _wrap_toward(raw_head, pre_head_rotation)
if DEBUG_RECOVERY:
print("[recover-start] head rel=%.2fdeg head_chain_offset=%.2fdeg raw=%.2fdeg pre=%.2fdeg final=%.2fdeg" % [
rad_to_deg(head_rel), rad_to_deg(head_chain_offset),
rad_to_deg(raw_head), rad_to_deg(pre_head_rotation), rad_to_deg(_head_bone.rotation)])
_head_bone.rotation = wrapf(_head_bone.rotation, -PI, PI)
var target_h := _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)
_recovery_bones.append({ "bone": _head_bone, "target": target_h })
if DEBUG_RECOVERY:
print("[stand-up] %s %.2fdeg -> %.2fdeg (sweep %.2fdeg)" % [
_head_bone.name, rad_to_deg(_head_bone.rotation), rad_to_deg(target_h), rad_to_deg(target_h - _head_bone.rotation)])
# Show the kinematic puppet first so it appears already in the captured
# pose, then re-enable IK to solve toward the end-effector markers.
# pose, then re-arm the stack (torso driver) with the limb TwoBoneIK mods
# and the head LookAt disabled so nothing re-solves the snapped bones.
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
_rearm_ik_stack()
_set_limb_mods_enabled(false)
if _head_look_at != null and is_instance_valid(_head_look_at):
_head_look_at.enabled = false
# Enable the two leg TwoBoneIK mods so the legs solve toward the ankle
# markers (clamped on the ground) while the torso marker rises. Match each
# leg's bend side to the captured pose so the re-solve cannot flip a knee.
for leg_joint: String in ["LeftLeg", "RightLeg"]:
var mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK
if mod == null or not is_instance_valid(mod):
continue
var leg_sides: Dictionary = _pre_ragdoll_state.get("leg_sides", {})
var entry: Dictionary = leg_sides.get(leg_joint, {})
if not entry.is_empty():
# Derive the captured knee side from the RAGDOLL capture in world
# space. The bone nodes are NOT reliable here: the torso marker's
# driver has not pushed the re-anchored hip yet, so bone globals
# would mix the stale entry hip with the snapped limb rotations
# and produce a garbage side (both knees flipping at the first
# solve).
var up_key: String = "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg"
var low_key: String = "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg"
var up_pose: Dictionary = _captured_pose.get(up_key, {})
var low_pose: Dictionary = _captured_pose.get(low_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)) + global_rotation)
var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + global_rotation)
var half_u := float(up_pose.get("half", 0.0))
var half_l := float(low_pose.get("half", 0.0))
var center_u := to_global(up_pose.get("pos", Vector2.ZERO))
var hip := center_u - dir_u * half_u
var knee := center_u + dir_u * half_u
var ankle := to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * half_l
var side := (ankle - hip).cross(knee - hip)
var captured_side := 1.0 if side >= 0.0 else -1.0
var entry_side := float(entry.get("side", captured_side))
var entry_flag := bool(entry.get("flag", false))
mod.flip_bend_direction = entry_flag if captured_side == entry_side else not entry_flag
if DEBUG_RECOVERY:
print("[leg-side] %s captured_side=%s entry_side=%s flag=%s" % [
leg_joint, str(captured_side), str(entry_side), str(mod.flip_bend_direction)])
mod.enabled = true
if DEBUG_RECOVERY:
for joint: String in BEND_JOINTS:
var dbg_mod: SkeletonModification2DTwoBoneIK = _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
print("[recover-start] ik %s enabled=%s" % [joint, str(dbg_mod != null and dbg_mod.enabled)])
print("[recover-start] lookat enabled=%s stack_enabled=%s" % [
str(_head_look_at != null and _head_look_at.enabled),
str(_skeleton != null and _skeleton.modification_stack != null and _skeleton.modification_stack.enabled)])
if DEBUG_RECOVERY and _body_container != null and is_instance_valid(_body_container):
var snap_parts: Array[String] = ["Body", "Head", "LeftUpperArm", "LeftLowerArm", "RightUpperArm", "RightLowerArm", "LeftUpperLeg", "LeftLowerLeg", "RightUpperLeg", "RightLowerLeg"]
for p: String in snap_parts:
var node := _body_container.get_node_or_null(NodePath(p)) as Node2D
if node != null and is_instance_valid(node):
print("[recover-snap-vis] %s rot=%.2fdeg" % [p, rad_to_deg(node.global_rotation)])
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
@@ -933,10 +1411,13 @@ func _set_marker_from_body(marker_name: String, body_key: String) -> void:
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))
# Far end (wrist / ankle) = body center + segment direction * half, in WORLD
# space (the captured angle is root-relative), converted back to local via
# to_local so the LEFT X-mirror and rig root rotation are handled exactly.
var dir := Vector2.from_angle(float(pose.get("rot", 0.0)) + global_rotation)
var half := float(pose.get("half", 0.0))
marker.position = pose.get("pos", marker.position) + dir * half
var center_world := to_global(pose.get("pos", Vector2.ZERO))
marker.position = to_local(center_world + dir * half)
func _get_ik_marker(name: String) -> Marker2D:
@@ -946,13 +1427,23 @@ func _get_ik_marker(name: String) -> Marker2D:
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.
## Stand-up: starts the tween from the captured ragdoll pose to the PRE-ragdoll
## captured state (sine ease-in-out, same duration). 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/bones.
##
## The per-bone tween targets (minimal world-direction sweeps) are computed and
## stored in `_recovery_bones` by `_snap_skeleton_to_pose()`; this function only
## starts the marker tween and appends one bone-rotation tween per entry.
func _play_stand_up() -> void:
_stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION)
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
if _stand_up_tween != null:
for entry: Dictionary in _recovery_bones:
var bone: Bone2D = entry.get("bone")
if bone == null or not is_instance_valid(bone):
continue
_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)
@@ -976,9 +1467,24 @@ func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
## Stand-up tween complete: settle into ANIMATED.
func _on_stand_up_finished() -> void:
if DEBUG_RECOVERY and _body_container != null and is_instance_valid(_body_container):
var end_parts: Array[String] = ["Body", "Head", "LeftUpperArm", "LeftLowerArm", "RightUpperArm", "RightLowerArm", "LeftUpperLeg", "LeftLowerLeg", "RightUpperLeg", "RightLowerLeg"]
for p: String in end_parts:
var node := _body_container.get_node_or_null(NodePath(p)) as Node2D
if node != null and is_instance_valid(node):
print("[recover-end-vis] %s rot=%.2fdeg" % [p, rad_to_deg(node.global_rotation)])
# Restore the pre-ragdoll facing profile + per-joint bend flags so the
# re-enabled IK holds the exact pre-ragdoll pose.
set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile))))
var bends: Dictionary = _pre_ragdoll_state.get("bends", {})
for joint: String in BEND_JOINTS:
if bends.has(joint):
set_joint_bend_flipped(joint, bool(bends[joint]))
_set_limb_mods_enabled(true)
_restore_head_lookat_state()
_recovery_bones.clear()
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = true
pass
if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true
_body_container.modulate.a = 1.0
@@ -1039,7 +1545,9 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
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)
body.set_meta("radius", float(entry["radius"]))
if not _clone_part_visuals(body, str(entry.get("visual_path", ""))):
_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):
@@ -1077,12 +1585,63 @@ func _build_ragdoll_body(entry: Dictionary) -> void:
# 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)
body.set_meta("radius", float(entry["radius"]))
if not _clone_part_visuals(body, str(entry.get("visual_path", ""))):
_add_ragdoll_visual_capsule(body, length, float(entry["radius"]), RAGDOLL_VISUAL_COLOR)
_ragdoll_root.add_child(body)
_ragdoll_bodies[key] = body
## Clones the part node's mounted shape children (Line2D open outlines /
## Polygon2D closed fills, added by StkRigAdapter as children of the Body/*
## part node) into the ragdoll body's local frame, so the ragdoll shows the
## stickman's own geometry instead of the gray fallback capsule/circle. Returns
## true if at least one shape was cloned.
func _clone_part_visuals(body: RigidBody2D, visual_path: String) -> bool:
if visual_path.is_empty():
return false
var source := get_node_or_null(NodePath(visual_path)) as Node2D
if source == null or not is_instance_valid(source):
return false
var cloned := 0
for child: Node in source.get_children():
if child is Line2D:
var src := child as Line2D
var copy := Line2D.new()
var pts := PackedVector2Array()
for p: Vector2 in src.points:
pts.append(body.to_local(src.to_global(p)))
copy.points = pts
copy.width = src.width
copy.default_color = src.default_color
if src.gradient != null:
copy.gradient = src.gradient
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
copy.antialiased = src.antialiased
copy.position = Vector2.ZERO
copy.name = "Visual" + str(body.get_child_count())
body.add_child(copy)
cloned += 1
elif child is Polygon2D:
var src := child as Polygon2D
var copy := Polygon2D.new()
var pts := PackedVector2Array()
for p: Vector2 in src.polygon:
pts.append(body.to_local(src.to_global(p)))
copy.polygon = pts
copy.color = src.color
copy.antialiased = src.antialiased
copy.position = Vector2.ZERO
copy.name = "Visual" + str(body.get_child_count())
body.add_child(copy)
cloned += 1
return cloned > 0
## 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
@@ -1132,22 +1691,34 @@ func _build_ragdoll_joint(entry: Dictionary) -> void:
pin.node_b = pin.get_path_to(child_body)
pin.softness = RAGDOLL_TARGET_SOFTNESS
_apply_ragdoll_joint_limits(pin, entry["limit"])
if DEBUG_RECOVERY:
# Diagnostic: profile + the applied band + the joint's INITIAL relative
# angle (child vs parent body) — if the initial angle sits outside the
# band, the pin re-folds the limb at ragdoll entry.
var rel := wrapf(child_body.rotation - parent_body.rotation, -PI, PI)
print("[ragdoll-joint] %s limit=%s profile=%s band=[%.1f, %.1f]deg initial_rel=%.1fdeg" % [
child_key, entry["limit"], FacingProfile.keys()[facing_profile],
rad_to_deg(pin.angular_limit_lower), rad_to_deg(pin.angular_limit_upper),
rad_to_deg(rel)])
## Applies angular limits to a ragdoll PinJoint2D. The natural elbow/knee fold
## follows the LIMB (encoded in the static `limit` strings: left arm/right leg
## fold +CW, right arm/left leg fold -CCW) and is profile-independent — the IK
## bend flags and the LEFT whole-rig mirror do NOT change it (the ragdoll's
## world fold direction stays with the limb). `shoulder_hip` is ±160°; any
## other `limit` leaves the joint free.
func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String) -> void:
match limit:
"elbow_knee":
# Fold (natural bend) toward +CW, resist hyperextension past -5°.
"elbow_knee", "elbow_knee_ccw":
var ccw := limit == "elbow_knee_ccw"
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)
if ccw:
pin.angular_limit_lower = -deg_to_rad(150.0)
pin.angular_limit_upper = deg_to_rad(5.0)
else:
pin.angular_limit_lower = -deg_to_rad(5.0)
pin.angular_limit_upper = deg_to_rad(150.0)
"shoulder_hip":
pin.angular_limit_enabled = true
pin.angular_limit_lower = -deg_to_rad(160.0)
+411
View File
@@ -0,0 +1,411 @@
# Ragdoll Cleanup — Architecture Spec
Source plan: `plans/RAGDOLL_CLEANUP.md`
Target file: `scripts/stickman_rig.gd` (only file changed)
## Verified code facts
- `_capture_ragdoll_pose()` (line 771) stores `_captured_ground_y = torso.global_position.y +
RAGDOLL_TORSO_RADIUS` — the floating bug. `_reanchor_root_to_landing()` (line 809) consumes it.
- Ragdoll bodies live on `collision_layer/mask = 1` (same as terrain) → a ground raycast MUST
exclude them.
- At runtime each `Body/*` part node's own geometry is cleared by `StkRigAdapter` and the
`.stk` shapes are mounted as **children** (`Line2D` open / `Polygon2D` closed) — so visual
cloning must walk the part node's *children*, not the node itself.
- Per-joint bend direction is stored in the 4 exported vars and queryable via
`get_joint_bend_flipped(joint)`; `facing_profile` applies via `PROFILE_FLAGS` + a whole-rig
X-mirror for LEFT (`Master.scale.x = -1`).
- The plan's §4.2 flag table is **outdated**. Actual `PROFILE_FLAGS` in code:
`LEFT == RIGHT == {LeftArm: inv, RightArm: inv, LeftLeg: norm, RightLeg: norm}`,
`FORWARD == {norm, inv, inv, norm}`. This spec uses the code's flags.
---
## Fix 1 — Recovery ground probe (raycast)
Replace the torso-radius ground guess with a downward raycast performed **inside
`_capture_ragdoll_pose()`** (while the ragdoll still exists — RIDs are needed for exclusion).
New constants:
```gdscript
const GROUND_PROBE_START_OFFSET := 50.0 # ray starts this far above the ragdoll top
const GROUND_PROBE_END_OFFSET := 1500.0 # ray ends this far below the ragdoll bottom
const GROUND_PROBE_TOLERANCE := 200.0 # accepted-hit band around the ragdoll extent
const GROUND_PROBE_X_OFFSETS := [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
```
New helper `_probe_ground_y(world_x: float, top_y: float, bottom_y: float,
exclude: Array[RID]) -> float`:
- Fans 7 rays at `world_x +` each offset, from `(x, top_y − START_OFFSET)` down to
`(x, bottom_y + END_OFFSET)`.
- `PhysicsRayQueryParameters2D`, `collision_mask = 1`, `collide_with_bodies = true`,
`collide_with_areas = false`, `query.exclude = exclude` (all ragdoll body RIDs).
- Accept only hits inside the band `top_y − TOLERANCE .. bottom_y + TOLERANCE` (rejects
overhang hits above and gap hits far below); among accepted hits keep the one whose `y`
is closest to `bottom_y`.
- Return the best hit `y`, or `bottom_y` as fallback (the ragdoll rests on *something*
there even if no ray found it).
`_capture_ragdoll_pose()` changes:
- Store `body.set_meta("radius", …)` at build time (`_build_ragdoll_body`).
- Compute `top_y` / `bottom_y` from each body's `pos.y ± (half + radius)` (head circle:
`half = 0`, `radius = 100`).
- `_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)`.
`_reanchor_root_to_landing()` — **unchanged**.
## Fix 2 — Ragdoll visual identity
- Add `"visual_path"` to each `RAGDOLL_BODIES` entry (`Body/Body`, `Body/Head`,
`Body/LeftUpperArm`, … — the `Body/*` part node).
- New `_clone_part_visuals(body: RigidBody2D, visual_path: String) -> bool`:
- Look up the part node; for each child that is `Line2D` or `Polygon2D`, create a same-type
node, copy visual props (points/polygon, `width`, `default_color`, cap/joint modes,
`closed`, `antialiased`), and **bake each point into the ragdoll body's local frame**:
`body.to_local(src.to_global(p))`; copy `position = Vector2.ZERO`.
- `add_child` the copies; return `true` if at least one shape was cloned.
- `_build_ragdoll_body()` calls the cloner; on `false` (missing part node / no mounted
shapes / empty bbox part) fall back to the existing gray capsule / white circle helpers
(`_add_ragdoll_visual_capsule` / `_add_ragdoll_visual_circle` become fallback-only).
- Physics is untouched — collision stays capsule/circle; visuals are cosmetic overlays
parented to the rigidbody (rotate/translate with it automatically).
## Fix 3 — Profile-aware ragdoll joint limits
- New const map from ragdoll child key → bend joint name:
```gdscript
const RAGDOLL_JOINT_BEND_JOINTS: Dictionary = {
"left_lower_arm": "LeftArm", "right_lower_arm": "RightArm",
"left_lower_leg": "LeftLeg", "right_lower_leg": "RightLeg",
}
```
- `_build_ragdoll_joint()` passes the child key; `_apply_ragdoll_joint_limits(pin, limit,
child_key)`:
- `elbow_knee`/`elbow_knee_ccw` entries: `flipped := get_joint_bend_flipped(
RAGDOLL_JOINT_BEND_JOINTS[child_key])`, then **mirror-corrected**:
`flipped_world := flipped != (facing_profile == FacingProfile.LEFT)` — the LEFT whole-rig
X-mirror negates world-space relative angles.
- `flipped_world == true` → band `[-150°, +5°]`; else band `[-5°, +150°]`.
- `shoulder_hip` ±160° and `neck` free: unchanged.
- Uses the **live** per-joint flags, so it covers the facing profile *and* harness
right-click overrides. FORWARD reproduces today's static config exactly.
## Implementation order
1. Fix 1 (probe constants + `_probe_ground_y` + capture changes + radius meta).
2. Fix 2 (`visual_path` entries + `_clone_part_visuals` + fallback wiring).
3. Fix 3 (joint map + mirror-corrected limits).
4. Headless `--check-only` validation of `scripts/stickman_rig.gd`.
## Acceptance criteria (from plan §7, unchanged)
- Recovery: feet on terrain on flat/slopes/ramp edges; no floating/clipping.
- Visuals: ragdoll shows the stickman's own colors/shapes; no gray bones; smooth handoff.
- Profiles: LEFT/RIGHT/FORWARD bend correctly in ragdoll and after recovery; no hyperextension.
- No new console errors; ANIMATED behavior untouched.
---
# Round 2 — Recovery snap & tween (post-test bugs)
User-reported symptoms:
- Walking RIGHT → ragdoll → recover: **arms/legs jump** to a new pose at tween start.
- Walking LEFT → ragdoll → recover: **whole figure (incl. torso) jumps**; body lands left of
the ragdoll pile; "feet end up where the torso should be".
## Root causes
1. **Mirror-unaware snap math (LEFT).** `_snap_skeleton_to_pose()` / `_set_marker_from_body()`
add *world-space* direction vectors to *rig-local* positions, and use the captured relative
angle directly for the Torso marker rotation. Under the LEFT whole-rig X-mirror
(`Master.scale.x = -1`) the root's `global_rotation` decomposes as `π − φ`, so the captured
relative angle equals `−A` (the negated unmirrored chain angle), not `A`. Result: the Torso
renders ~180° off and every joint offset is X-mirrored — the whole-body jump.
2. **IK re-solve at snap (RIGHT/FORWARD).** After the snap re-arms the modification stack,
TwoBoneIK re-solves the limbs toward the captured end-effector markers using the profile's
`flip_bend_direction` flags. If the ragdoll fell with a knee/elbow on the other side of the
solution line, the limb snaps to the mirrored IK solution at tween start.
## Fix design (approved)
- **Mirror-safe snap math:** convert every derived joint point through
`to_local(to_global(captured_pos) + world_dir * half)` — the transform math itself handles the
mirror and any root rotation. Captured relative angles are converted to chain angles via
`_chain_angle_from_captured(x) = −x` for LEFT, else `x`.
- **Bone-driven limb recovery:** during RECOVERING the 4 TwoBoneIK mods are **disabled** and the
8 limb `Bone2D` rotations are driven directly:
- `_ready()` resolves the 8 limb bones (`LIMB_BONE_PATHS`) and captures their authored rest
rotations (`_rest_bone_rotations`) before the first IK solve.
- `_snap_skeleton_to_pose()` sets each limb bone's local rotation from the captured body
angles: `rot_u = A_u − A_torso − bone_angle_u`, `rot_l = A_l − A_u − bone_angle_l`
(mirror-corrected chain angles), then disables the 4 TwoBoneIK mods
(`_set_limb_mods_enabled(false)`).
- `_play_stand_up()` tweens the snapped bones **in parallel** with the marker tween (same
duration/ease) to their rest rotations.
- `_on_stand_up_finished()` and `snap_to_standing()` re-enable the 4 mods — at that point
markers are at STAND_POSE and bones at rest, so the resumed IK solve reproduces the authored
standing pose exactly (no end snap).
## Round 2 acceptance criteria
- [ ] RIGHT: ragdoll → recover transitions with no limb jump at tween start or end.
- [ ] LEFT: ragdoll → recover snaps back to the exact ragdoll pose (no whole-body jump); figure
stands up at the landing spot with feet on the terrain.
- [ ] FORWARD: unchanged smoothness; existing recovery behavior intact.
- [ ] Interrupted recovery (ragdoll re-entry mid-stand-up) and `snap_to_standing()` still work;
no console errors.
---
# Round 3 — Pre-ragdoll state capture + spine-offset fix
User-reported (after Round 2): the first recovery frame still shows arms/legs at wrong
positions ("the first frame of recover changes where the arms and legs are before the tween").
User-prescribed design:
1. Capture the stickman's state on the last frame **before** ragdoll (limbs, head, torso,
facing profile, bend flags) — this is the **recovery END target** (possibly a mid-walk
pose — the animation does not resume, but the transforms are retained).
2. Capture the state on the last frame **of** ragdoll — the **tween START**.
3. Tween every bone/marker from the ragdoll state to the pre-ragdoll state.
## Root cause of the Round 2 jump (verified against master_rig.tscn)
The Torso **bone's** direction is not the spine. The Head bone's authored local position is
`(0, −391.5)` — the spine runs along the Torso bone's **−Y**, not its +X. Therefore:
- `torso_marker.rotation = chain_angle(spine_rel) + π/2 − bone_angle_torso` (new const
`TORSO_SPINE_OFFSET := PI / 2.0`); at rest `spine_rel = −π/2` → marker rotation `0` = STAND_POSE ✓.
- The limbs' parent chain angle is the **bone** angle `θ_t = marker.rotation + bone_angle_t
= chain(spine_rel) + π/2` — Round 2 used `chain(spine_rel)` directly, so all 8 limb rotations
were ~90° off at snap. The limb formula itself (`rot = chain(dir) − parent_chain − bone_angle`,
where the ragdoll body angle IS the bone *direction* and the Body/* visuals follow the bone
*node* rotation) is otherwise correct.
- The head bone drives `Body/Head` via its node rotation (bone_angle excluded from node
transforms): `head.rotation = chain(head_body_rel) − θ_t` (no `bone_angle` term). Round 2
never set the head bone — the re-armed LookAt rewrote it on the first frame (head jump).
## Fix design (approved)
- **Pre-ragdoll capture** `_capture_pre_ragdoll_state()` at the top of `_enter_ragdoll()`:
the 6 marker local positions + rotations, the 8 limb bone rotations, the head bone rotation,
`facing_profile`, the 4 live bend flags, and the head LookAt's enabled state.
- **Corrected snap** `_snap_skeleton_to_pose()`:
- torso marker rotation `+ TORSO_SPINE_OFFSET`;
- limb rotations with parent chain `torso_chain + TORSO_SPINE_OFFSET`;
- NEW: head bone rotation from the head body (no bone_angle term);
- all snapped rotations wrapped to the shortest path toward their pre-ragdoll targets
(`_wrap_toward(v, target) = target + wrapf(v − target, −PI, PI)`) so the tween never takes
the long way around;
- disable the 4 TwoBoneIK mods AND the head LookAt.
- **Tween** `_play_stand_up()`: markers → pre-ragdoll marker values; 8 limb bones + head bone →
pre-ragdoll rotations (same parallel sine ease, 2.0 s).
- **Finish** `_on_stand_up_finished()`: restore the captured facing profile + per-joint bend
flags + head LookAt enabled state, re-enable the 4 limb mods → the resumed IK solve reproduces
the pre-ragdoll pose exactly (no end snap). `snap_to_standing()` restores the LookAt state too.
- `_pin_mirrored_head_rotation()` gated to ANIMATED only (its per-frame pin would fight the head
tween during RECOVERING).
- `_rest_bone_rotations` removed (rest pose is no longer a tween target).
## Round 3 acceptance criteria
- [ ] First recovery frame is pixel-identical to the last ragdoll frame (limbs, head, torso).
- [ ] Recovery ends in the exact pre-ragdoll pose (mid-walk poses retained; no animation resume),
profile + bend flags restored; no end-of-tween snap.
- [ ] LEFT/RIGHT/FORWARD all recover; interrupted recovery and `snap_to_standing()` still work;
no console errors.
---
# Round 4 — Snap angle corrections + runtime debug prints
User-reported (after Round 3): end of recovery matches the pre-ragdoll frame ✓, but the last
ragdoll frame and the first recovery frame are "completely different" — body/limbs/head all in
the wrong positions, head orientation wrong, arms rotated unrealistically, whole body ~90° off.
User requested runtime debug prints of each part/IK at the end of ragdoll and start of recovery.
## Root causes (verified against master_rig.tscn + `_build_ragdoll_body` math)
Chain-angle framework (validated): `chain(captured_rel) = A + bone_angle` for every bone,
where `A` is the unmirrored node-chain angle (bone_angle is NOT part of node transforms — only
the ragdoll body direction includes it; the Body/* visuals follow the node rotation).
1. **Lower-limb parent chain.** The lower bone's node parent chain is `A_upper` (no bone_angle),
but the captured upper body angle is `A_upper + bone_angle_upper`. Round 3 computed
`rot_l = chain(l) − chain(u) − bone_angle_l`, which is off by `−bone_angle_upper` —
LeftLowerArm flipped ~180° (bone_angle_upper = −π) and both lower legs off ~90°
(bone_angle_upper = +π/2). Correct:
`rot_l = chain(l) − chain(u) + bone_angle_upper − bone_angle_lower`
(validated: reproduces every authored rest rotation exactly).
2. **Head entry offset.** The head ragdoll body is a "visual" body spawned with rotation 0 (a
circle, not a direction bone), so its cloned visual carries the ENTRY head chain as a baked
world offset `V_entry = rendered(θ_t_entry + rot_h_entry)`. Round 3 used
`rot_h = chain(h_rel) − θ_t_snap`, dropping `V_entry` and the LEFT `−π` mirror term. Correct:
`rot_h = chain(h_rel) + head_entry_offset + (θ_t_entry + rot_h_entry) − θ_t_snap`
where `head_entry_offset = +φ` (FORWARD/RIGHT) or `−(π − φ)` (LEFT), `φ = global_rotation`,
and `θ_t_entry + rot_h_entry` = pre-ragdoll Torso marker rotation + pre-ragdoll head rotation.
3. **Debug prints** (user-requested, gated by `DEBUG_RECOVERY`, set **true** for this round):
- `_capture_ragdoll_pose()`: per body — key, world pos/rot, local pos, relative rot.
- `_snap_skeleton_to_pose()`: torso chain/raw/wrapped marker rotation + pre target; per joint
the upper/lower chain angles, computed rotations, pre-ragdoll targets; head raw/wrapped/
target; final TwoBoneIK mod + LookAt enabled states.
## Round 4 acceptance criteria
- [ ] First recovery frame is pixel-identical to the last ragdoll frame for ALL profiles.
- [ ] No 90°/180° limb flips; head orientation continuous across the transition.
- [ ] Console debug output shows matching angles between the ragdoll-end and recovery-start
frames (flip `DEBUG_RECOVERY` back to `false` once verified).
- [ ] End of recovery still matches the pre-ragdoll frame; no console errors.
---
# Round 5 — Entry-offset snap + mirror-aware re-basing (from user debug data)
User-provided LEFT-profile debug output revealed three bugs (the end-of-recovery match and the
limb formulas were already correct; the limb debug values reconcile exactly once the torso
parent is right).
## Findings from the debug data
1. **Torso marker rotation double-count (~90° whole-body rotation).** The Torso bone's
`bone_angle` is AUTO-CALCULATED (no `auto_calculate_length_and_angle = false` in the tscn)
from its first child (the Head bone at `(0, −391.5)`) → `bone_angle_t = −90°`. The original
`chain − bone_angle_t` therefore already encodes the spine offset; Round 3/4's additional
`+TORSO_SPINE_OFFSET` double-counted it: `raw = chain + 180` instead of `chain + 90`
(verified: printed `raw=147.64`, correct `57.62`).
2. **Mirror-unaware re-basing (~552 px X shift).** `_reanchor_root_to_landing()` re-bases the
captured rig-local positions by `+ shift` (the world-space root delta). Under the LEFT
X-mirror a local +X moves world −X, so every captured position lands off by `2·shift.x`
(verified: `hip_local.x = 386.38` vs expected `−165.25`, difference = `2·275.85`).
3. **Head entry offset doubled (head 180° off).** Round 4's `entry_offset` used
`global_rotation`, which Godot decomposes as `−π` for the mirrored root (not `π − φ`),
producing `−360°` instead of `−180°`.
## Fix design (approved — derived from the user's own debug output)
- **Entry-offset capture** in `_capture_pre_ragdoll_state()`:
- `torso_chain_offset = chain(spine_entry_rel) − θ_t_entry`, where `spine_entry_rel` is the
entry spine direction (head bone − torso bone world positions, minus `global_rotation`) and
`θ_t_entry` the entry Torso marker rotation.
- `head_chain_offset = chain(−global_rotation) − (θ_t_entry + head_rotation_entry)` — the
head ragdoll body spawns with rotation 0, so this is its baked entry offset.
- The `chain()` form is branch-proof: the mirrored root's `global_rotation` decomposition
(±π) cancels in the difference of two captured angles.
- **Snap** `_snap_skeleton_to_pose()`:
- `θ_t = wrap_toward(torso_chain − torso_chain_offset, pre_torso_marker_rot)`; the torso
marker rotation AND the limb parent chain both use this single `θ_t` value.
- `rot_h = wrap_toward(chain(head_rel) − head_chain_offset − θ_t, pre_head_rotation)`.
- `TORSO_SPINE_OFFSET`, the `− bone_angle_t` subtraction, and the Round 4 head
`entry_offset`/`entry_chain` reconstruction are all REMOVED — the captured offsets absorb
every authored convention.
- **Mirror-aware re-basing** in `_reanchor_root_to_landing()`:
`local_shift = to_local(shift) − to_local(Vector2.ZERO)` (the root basis's inverse applied to
the world delta — negates X under LEFT, handles root rotation too).
## Round 5 acceptance criteria
- [ ] Debug output for LEFT: `[recover-start] torso ... raw≈57.6 final≈57.6`, hip_local ≈
(−165.2, 343.3), head final ≈ −57.6.
- [ ] First recovery frame pixel-identical to the last ragdoll frame (all profiles).
- [ ] Recovery ends at the pre-ragdoll pose at the landing spot; no console errors.
- [ ] Flip `DEBUG_RECOVERY` back to `false` once verified.
---
# Round 6 — Natural joint-bend tween paths (no unwrapping)
User-reported (after Round 5): positions now match, but limbs **unwrap unrealistically** — a
knee sweeps the long/backwards way (≈315°) instead of a natural short bend (≈45°). "The body
should recover just as smoothly as it ragdolls."
## Root cause (verified against the user's FORWARD debug data)
Each bone rotation tweens along the **shortest value path** toward its pre-ragdoll value. For a
joint that fell bent backwards, the shortest path sweeps **through the ±180° (fully-backward)
side** — the knee folds further backwards through the back of the joint — instead of through
straight (0), the only natural humanoid path. Example from the data: left lower leg value
`286.34` (≡ −73.7°, bent backwards) → pre `120.40`: Round 5 tweens `286.34 → 120.40` (−165.9°,
through −180°, backwards) instead of `286.34 → 480.40` (+194.1°, through 0/straight).
## Fix design (approved)
New helper `_natural_joint_target(current, target) -> float`:
- `a = wrapf(current, −π, π)`, `b = wrapf(target, −π, π)`, `d = wrapf(b − a, −π, π)`.
- If `|a| + |b| > π` the shortest path crosses ±π (the joint would fold backwards) — take the
long way around through straight: `d += 2π` if `d < 0` else `d −= 2π`.
- Return `current + d` (the pre value shifted by whole turns, so the tween END still renders the
pre rotation).
- Applied to the 8 limb bones' tween targets in `_play_stand_up()` (NOT the torso marker or the
head — single segments, shortest path stays natural).
## Round 6 acceptance criteria
- [ ] No joint sweeps through the ±180° backward side during the stand-up; elbows/knees pass
through straight when the ragdoll bent them backwards.
- [ ] Every limb segment's world-direction sweep stays ≤ 180°; recovery reads as smooth as the
ragdoll fall.
- [ ] End pose still exactly the pre-ragdoll pose (targets differ by whole turns only).
- [ ] All profiles; no console errors. Flip `DEBUG_RECOVERY` to `false` once verified.
---
# Round 7 — Natural-bend rule restricted to elbows/knees
User-reported (after Round 6): "made it worse, now limbs are going fully past 360 degrees."
Verified against the RIGHT-profile debug data: Round 6 applied the avoid-±π rule to ALL 8
limb bones, so the LeftArm SHOULDER took the long way (`−185.40 → −450.98`, a −265.6° sweep;
world sweep −326.2°) instead of the natural shortest +94.4°.
## Correction
- **Shoulders/hips** (the 4 upper bones) are ball joints — keep the plain shortest path
(the snap's existing wrap; Round 5 behavior). Revert Round 6 for the upper bones.
- **Elbows/knees** (the 4 lower bones) keep `_natural_joint_target` — a hinge joint bent
backwards must pass through straight (0) instead of folding further through ±π. This was
Round 6's correct half and fixes Round 5's −307° lower-leg world spin (now +52.9°).
- Debug: print each bone's stand-up tween (name, from → to) so the sweeps are visible.
## Round 7 acceptance criteria
- [ ] No shoulder/hip sweep exceeds 180°; no elbow/knee folds backwards through ±π.
- [ ] Every limb segment's world-direction sweep ≤ 180° for all profiles.
- [ ] Console shows `[stand-up] <bone> <from> -> <to>` lines; spot-check the sweeps.
- [ ] End pose still exactly pre-ragdoll; no errors. Flip `DEBUG_RECOVERY` to `false` when done.
---
# Round 8 — Minimal world-sweep tween targets (per-segment branch selection)
User-reported (after Round 7): limbs still flip ~360°. Verified against the RIGHT debug data:
the per-bone rules compose badly — RightLeg upper swept +62.3° and the Round-7 knee rule chose
+200.3° (through straight), so the lower leg's WORLD direction swept +262.6° — a near-full spin.
The flaw: choosing each bone's branch in isolation ignores the parent chain's simultaneous
sweep.
## Fix design (approved)
For each bone in chain order, choose the whole-turn branch of its value sweep
(`pre − start`, `±360°`) that **minimizes the segment's world-direction sweep**
(`Δθ_torso + ΣΔrot` up to and including that bone). Since the candidates are 360° apart,
exactly one lands within ±180° of the accumulated parent sweep — every segment's world sweep is
guaranteed ≤ 180°:
- torso marker: existing wrap (Δθ ≤ 180°).
- upper bone: pick `d_u ∈ {pre_u − start_u, ±360}` minimizing `|Δθ + d_u|`.
- lower bone: pick `d_l ∈ {pre_l − start_l, ±360}` minimizing `|Δθ + d_u + d_l|` (d_u already
chosen) — the knee/elbow branch follows the minimal world sweep, which is exactly the
"through straight when bent backwards" behavior, derived instead of heuristic.
- head: pick `d_h ∈ {pre_h − start_h, ±360}` minimizing `|Δθ + d_h|`.
- Targets are computed in `_snap_skeleton_to_pose()` (where the chains are known) and stored in
`_recovery_bones`; `_play_stand_up()` just tweens them. `_natural_joint_target()` removed.
Verified against the user's RIGHT data: RightLeg knee −159.7° (world −97.5°), LeftLeg knee
+183.6° (world +31.2°), LeftArm elbow −170.6° (world −128.2°); every segment sweep ≤ 180°.
## Round 8 acceptance criteria
- [ ] No limb segment's world direction sweeps > 180° for any profile; no visible spins.
- [ ] Knees/elbows pass through straight when the ragdoll bent them backwards (follows from the
minimal-world-sweep choice).
- [ ] `[stand-up]` lines show every segment sweep ≤ 180°; end pose exactly pre-ragdoll.
- [ ] No console errors. Flip `DEBUG_RECOVERY` to `false` once verified.