diff --git a/plans/RAGDOLL_CLEANUP.md b/plans/RAGDOLL_CLEANUP.md new file mode 100644 index 0000000..f812996 --- /dev/null +++ b/plans/RAGDOLL_CLEANUP.md @@ -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. diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index ea9280c..266e1b4 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -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. -func _play_stand_up() -> void: - _stand_up_tween = _tween_markers_to(STAND_POSE, STAND_UP_DURATION) +## 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: + 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) diff --git a/specs/ragdoll-cleanup.md b/specs/ragdoll-cleanup.md new file mode 100644 index 0000000..f604659 --- /dev/null +++ b/specs/ragdoll-cleanup.md @@ -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] -> ` 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.