Refactor ragdoll recovery system: implement joint bend limits and recovery path overrides

- Updated `DEBUG_RECOVERY` to false for production.
- Introduced `RAGDOLL_JOINT_BAND_LIMITS` for customizable joint bend limits based on facing profile.
- Added `RECOVERY_LEG_SIDE_FORCE` for manual control of leg IK bend direction during recovery.
- Implemented `RECOVERY_JOINT_PATH` to allow optional tween path overrides for elbow/knee recovery.
- Removed debug print statements related to recovery visuals to clean up output.
- Adjusted `_apply_ragdoll_joint_limits` to utilize new bend limits configuration.
- Enhanced documentation in `ragdoll-cleanup.md` to reflect changes in the recovery pipeline and tuning tables.
This commit is contained in:
2026-09-10 01:09:30 -04:00
parent 699b623be3
commit 63c64bd529
5 changed files with 276 additions and 164 deletions
+88 -112
View File
@@ -198,9 +198,8 @@ 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. TEMPORARILY true for
## the Round 4 transition debugging — flip back to false once verified.
const DEBUG_RECOVERY := true
## Debug gate for the ragdoll-recovery re-solve trace. Ship OFF.
const DEBUG_RECOVERY := false
## Prints a `[walk] `-prefixed message only when DEBUG_WALK is on.
func _walk_dbg(msg: String) -> void:
@@ -242,6 +241,64 @@ const RAGDOLL_JOINTS: Array[Dictionary] = [
{ "child": "right_lower_leg", "parent": "right_upper_leg", "pin_node_path": "Torso/RightUpperLeg/RightLowerLeg", "limit": "elbow_knee" },
]
## ---------------------------------------------------------------------------
## Joint bend configuration (EDIT ME)
## ---------------------------------------------------------------------------
## Every per-joint bend decision in the ragdoll cycle lives in these tables,
## keyed by facing profile, so each joint can be tuned individually without
## touching the pipeline code.
##
## RAGDOLL_JOINT_BAND_LIMITS[profile][joint] — the fall's knee/elbow PinJoint2D
## angular band, in DEGREES, as { "lower": ..., "upper": ... }:
## - The band is the allowed range of the joint's relative angle
## (shin vs thigh / forearm vs upper arm), measured in the pin's frame.
## - 0 = straight. Negative = folds one way; positive = folds the other.
## - Natural shape: a SMALL side (~5) that resists hyperextension the wrong
## way, and a LARGE side (~150) on the fold direction.
## - To reverse a joint's bend: swap the magnitudes, e.g.
## { "lower": -5, "upper": 150 } <-> { "lower": -150, "upper": 5 }.
## - lower must be <= upper (the joint angle must stay inside the band).
## Missing joint entries fall back to the RAGDOLL_JOINTS `limit` string.
const RAGDOLL_JOINT_BAND_LIMITS: Dictionary = {
FacingProfile.FORWARD: {
"left_lower_arm": { "lower": -150.0, "upper": 5.0 },
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": -5.0, "upper": 150.0 },
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
},
FacingProfile.LEFT: {
"left_lower_arm": { "lower": 60.0, "upper": 100.0 },
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": -100.0, "upper": -90.0 },
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
},
FacingProfile.RIGHT: {
"left_lower_arm": { "lower": -100.0, "upper": -60.0 },
"right_lower_arm": { "lower": -150.0, "upper": 5.0 },
"left_lower_leg": { "lower": 90.0, "upper": 100.0 },
"right_lower_leg": { "lower": -5.0, "upper": 150.0 },
},
}
## RECOVERY_LEG_SIDE_FORCE[profile][leg] — optional manual override of the leg
## IK `flip_bend_direction` during the stand-up (true = inverted). A missing
## key means automatic: match the captured fall pose's knee side.
const RECOVERY_LEG_SIDE_FORCE: Dictionary = {
FacingProfile.FORWARD: {},
FacingProfile.LEFT: {},
FacingProfile.RIGHT: {},
}
## RECOVERY_JOINT_PATH[profile][joint] — optional override of the elbow/knee
## stand-up tween path: "shortest" = plain shortest rotation. A missing key
## means automatic: pass through straight when the shortest path would cross
## the +/-180 (fully-backward) boundary.
const RECOVERY_JOINT_PATH: Dictionary = {
FacingProfile.FORWARD: {},
FacingProfile.LEFT: {},
FacingProfile.RIGHT: {},
}
# ---------------------------------------------------------------------------
# Exported controls
# ---------------------------------------------------------------------------
@@ -355,7 +412,6 @@ 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] = []
@@ -471,7 +527,6 @@ func _physics_process(delta: float) -> void:
_update_speech(delta)
_update_runner(delta)
_pin_mirrored_head_rotation()
_debug_recovery_visuals(delta)
@@ -922,10 +977,6 @@ func _capture_ragdoll_pose() -> void:
"rot": body.global_rotation - global_rotation,
"half": half,
}
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).
@@ -933,8 +984,6 @@ func _capture_ragdoll_pose() -> void:
if torso != null and is_instance_valid(torso):
_captured_landing_center = torso.global_position
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)
if 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).
@@ -1071,36 +1120,6 @@ func _recovery_dbg() -> void:
])
## 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),
@@ -1221,10 +1240,6 @@ func _snap_skeleton_to_pose() -> void:
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:
@@ -1285,12 +1300,6 @@ func _snap_skeleton_to_pose() -> void:
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
@@ -1313,15 +1322,16 @@ func _snap_skeleton_to_pose() -> void:
# 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))
# instead of being sent the long way around. A "shortest" entry in
# RECOVERY_JOINT_PATH skips the natural rule entirely.
var target_l: float
var path_override := String(RECOVERY_JOINT_PATH.get(facing_profile, {}).get(joint, ""))
if path_override == "shortest":
target_l = lower.rotation + wrapf(pre_l - lower.rotation, -PI, PI)
else:
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
@@ -1330,16 +1340,9 @@ func _snap_skeleton_to_pose() -> void:
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-arm the stack (torso driver) with the limb TwoBoneIK mods
@@ -1360,7 +1363,11 @@ func _snap_skeleton_to_pose() -> void:
continue
var leg_sides: Dictionary = _pre_ragdoll_state.get("leg_sides", {})
var entry: Dictionary = leg_sides.get(leg_joint, {})
if not entry.is_empty():
var force_profile: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(facing_profile, {})
if force_profile.has(leg_joint):
# Manual override from the editable RECOVERY_LEG_SIDE_FORCE table.
mod.flip_bend_direction = bool(force_profile[leg_joint])
elif 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
@@ -1385,23 +1392,7 @@ func _snap_skeleton_to_pose() -> void:
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:
@@ -1467,12 +1458,6 @@ 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))))
@@ -1690,35 +1675,26 @@ func _build_ragdoll_joint(entry: Dictionary) -> void:
pin.node_a = pin.get_path_to(parent_body)
pin.node_b = pin.get_path_to(child_body)
pin.softness = RAGDOLL_TARGET_SOFTNESS
_apply_ragdoll_joint_limits(pin, entry["limit"])
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)])
_apply_ragdoll_joint_limits(pin, entry["limit"], str(entry["child"]))
## 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:
## Applies angular limits to a ragdoll PinJoint2D. The elbow/knee band comes
## from the editable RAGDOLL_JOINT_BAND_LIMITS table (per profile, per joint,
## in degrees); the static `limit` string is only a fallback for joints
## missing from the table. `shoulder_hip` is ±160°; other limits leave the
## joint free.
func _apply_ragdoll_joint_limits(pin: PinJoint2D, limit: String, child_key: String = "") -> void:
match limit:
"elbow_knee", "elbow_knee_ccw":
var ccw := limit == "elbow_knee_ccw"
var band: Dictionary = RAGDOLL_JOINT_BAND_LIMITS.get(facing_profile, {}).get(child_key, {})
if band.is_empty():
if limit == "elbow_knee":
band = { "lower": -5.0, "upper": 150.0 }
else:
band = { "lower": -150.0, "upper": 5.0 }
pin.angular_limit_enabled = true
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)
pin.angular_limit_lower = deg_to_rad(float(band.get("lower", -5.0)))
pin.angular_limit_upper = deg_to_rad(float(band.get("upper", 150.0)))
"shoulder_hip":
pin.angular_limit_enabled = true
pin.angular_limit_lower = -deg_to_rad(160.0)