Implement Ragdoll Cleanup: Fix recovery position offset, visual identity, and profile persistence
- Added a new implementation plan for addressing ragdoll issues in stickman character. - Resolved recovery position offset by replacing torso-radius ground calculation with a downward raycast. - Enhanced ragdoll visual identity by ensuring ragdoll bodies inherit colors and shapes from original Body nodes. - Implemented profile persistence in ragdoll mode to respect joint bending based on facing profile. - Updated `stickman_rig.gd` to include necessary changes for the above fixes. - Created architecture specification detailing the implementation and acceptance criteria for each fix.
This commit is contained in:
+633
-62
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user