Refactor stickman rig and ragdoll mechanics; enhance pose restoration and stability
- Disabled constraints for the stickman rig's head bone to allow for smoother animations. - Improved mode transitions in the sandbox stage to preserve stickman poses between EDIT and DIRECT modes. - Added side profile stabilization for knee bends in the ragdoll component to prevent unnatural poses during physics interactions. - Enhanced the snap-to-standing functionality to restore the stickman's original pose accurately. - Introduced new tests for validating pose preservation during mode transitions and ragdoll behavior during walking animations. - Created a new scene for testing stickman bending and ragdoll transitions.
This commit is contained in:
+19
-11
@@ -644,6 +644,7 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
|
||||
func set_mode(mode: StageMode) -> void:
|
||||
if mode == current_mode:
|
||||
return
|
||||
var previous_mode: StageMode = current_mode
|
||||
if mode == StageMode.PLAY and not _cli_startup:
|
||||
if not _save_scene_to_path(AUTOSAVE_PATH, true):
|
||||
push_warning("SandboxStage: autosave before Play failed.")
|
||||
@@ -652,9 +653,9 @@ func set_mode(mode: StageMode) -> void:
|
||||
_cancel_palette_drag()
|
||||
match mode:
|
||||
StageMode.EDIT:
|
||||
_enter_edit_mode()
|
||||
_enter_edit_mode(previous_mode)
|
||||
StageMode.DIRECT:
|
||||
_enter_direct_mode()
|
||||
_enter_direct_mode(previous_mode)
|
||||
StageMode.PLAY:
|
||||
_enter_play_mode()
|
||||
_sync_mode_buttons()
|
||||
@@ -667,8 +668,8 @@ func set_mode(mode: StageMode) -> void:
|
||||
|
||||
|
||||
func _enter_edit_shared() -> void:
|
||||
# Snap stickmen straight back to their standing pose/position (no stand-up
|
||||
# tween glide), stopping any director queue first.
|
||||
# Restore stickmen to their authored spawn/ghost pose (no stand-up tween
|
||||
# glide), stopping any director queue first.
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
@@ -697,8 +698,11 @@ func _enter_edit_shared() -> void:
|
||||
_collision_pairs.clear()
|
||||
|
||||
|
||||
func _enter_edit_mode() -> void:
|
||||
_enter_edit_shared()
|
||||
func _enter_edit_mode(previous_mode: StageMode) -> void:
|
||||
# EDIT and DIRECT share the same frozen world. PLAY still needs the complete
|
||||
# simulation cleanup and authored-pose restore before editing resumes.
|
||||
if previous_mode != StageMode.DIRECT:
|
||||
_enter_edit_shared()
|
||||
_clear_director_pending()
|
||||
_set_edit_controls_visible(true)
|
||||
_set_direct_controls_visible(false)
|
||||
@@ -730,11 +734,15 @@ func _clear_director_pending() -> void:
|
||||
_director_visuals.clear_edit_waypoint()
|
||||
|
||||
|
||||
## Direct is internally an "edit-with-direct" state: same freeze/stand/gizmo
|
||||
## side effects, but with the spawner/grid controls hidden, the director hint
|
||||
## shown, and any placement/selection/pending-target cleared.
|
||||
func _enter_direct_mode() -> void:
|
||||
_enter_edit_shared()
|
||||
## Direct is internally an "edit-with-direct" state: the same frozen world,
|
||||
## but with the spawner/grid controls hidden, the director hint shown, and any
|
||||
## placement/selection/pending-target cleared.
|
||||
func _enter_direct_mode(previous_mode: StageMode) -> void:
|
||||
# EDIT and DIRECT share the same non-simulating world. Preserve the exact
|
||||
# placed stickman pose between them; a transition from PLAY still needs the
|
||||
# full cleanup and authored-pose restore.
|
||||
if previous_mode != StageMode.EDIT:
|
||||
_enter_edit_shared()
|
||||
set_placement_mode("")
|
||||
_selection.clear_selection()
|
||||
_clear_director_pending()
|
||||
|
||||
+91
-10
@@ -16,6 +16,8 @@ const REST_LINEAR_THRESHOLD: float = 5.0
|
||||
const REST_ANGULAR_THRESHOLD: float = 0.1
|
||||
const STABILIZATION_DELAY: float = 0.1
|
||||
const TARGET_SOFTNESS: float = 0.2
|
||||
const SIDE_PROFILE_STRAIGHT_THRESHOLD := deg_to_rad(5.0)
|
||||
const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0)
|
||||
const GROUND_PROBE_START_OFFSET := 50.0
|
||||
const GROUND_PROBE_END_OFFSET := 1500.0
|
||||
const GROUND_PROBE_TOLERANCE := 200.0
|
||||
@@ -65,8 +67,10 @@ const JOINTS: Array[Dictionary] = [
|
||||
{ "child": "head", "parent": "torso", "pin": "Torso/Head", "limit": "neck" }, { "child": "left_upper_arm", "parent": "torso", "pin": "Torso/LeftUpperArm", "limit": "shoulder" }, { "child": "left_lower_arm", "parent": "left_upper_arm", "pin": "Torso/LeftUpperArm/LeftLowerArm", "limit": "cw" }, { "child": "right_upper_arm", "parent": "torso", "pin": "Torso/RightUpperArm", "limit": "shoulder" }, { "child": "right_lower_arm", "parent": "right_upper_arm", "pin": "Torso/RightUpperArm/RightLowerArm", "limit": "ccw" }, { "child": "left_upper_leg", "parent": "torso", "pin": "Torso/LeftUpperLeg", "limit": "shoulder" }, { "child": "left_lower_leg", "parent": "left_upper_leg", "pin": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "ccw" }, { "child": "right_upper_leg", "parent": "torso", "pin": "Torso/RightUpperLeg", "limit": "shoulder" }, { "child": "right_lower_leg", "parent": "right_upper_leg", "pin": "Torso/RightUpperLeg/RightLowerLeg", "limit": "cw" },
|
||||
]
|
||||
|
||||
## Per-profile elbow/knee limits retained from the monolithic rig. The component
|
||||
## owns these construction settings because it creates the PinJoint2D network.
|
||||
## Per-profile elbow/knee limits. Side-profile leg hinges include the straight
|
||||
## pose (0 degrees) and allow both knees to fold toward the same profile-relative
|
||||
## side, avoiding an instant forced +/-90-degree fold at ragdoll handoff.
|
||||
## The component owns these settings because it creates the PinJoint2D network.
|
||||
const JOINT_BAND_LIMITS: Dictionary = {
|
||||
StickmanRig.FacingProfile.FORWARD: {
|
||||
"left_lower_arm": { "lower": -150.0, "upper": 5.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 },
|
||||
@@ -74,11 +78,11 @@ const JOINT_BAND_LIMITS: Dictionary = {
|
||||
},
|
||||
StickmanRig.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 },
|
||||
"left_lower_leg": { "lower": -150.0, "upper": 0.0 }, "right_lower_leg": { "lower": -150.0, "upper": 0.0 },
|
||||
},
|
||||
StickmanRig.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 },
|
||||
"left_lower_leg": { "lower": 0.0, "upper": 150.0 }, "right_lower_leg": { "lower": 0.0, "upper": 150.0 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -141,6 +145,14 @@ func initialize(skeleton: Skeleton2D) -> void:
|
||||
_previous_position = _rig.global_position
|
||||
_previous_rotation = _rig.global_rotation
|
||||
|
||||
|
||||
## Runs after the physics step and before drawing. Godot's PinJoint2D can let a
|
||||
## straight knee cross its angular limit during the step; correcting here keeps
|
||||
## that invalid intermediate pose from ever reaching the rendered frame.
|
||||
func _process(_delta: float) -> void:
|
||||
if _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL:
|
||||
_enforce_side_profile_leg_bends()
|
||||
|
||||
func _resolve_limb_bones() -> void:
|
||||
_limb_bones.clear()
|
||||
for joint: String in LIMB_BONE_PATHS:
|
||||
@@ -176,12 +188,75 @@ func build() -> bool:
|
||||
if parent == null: return false
|
||||
_root = Node2D.new(); _root.name = CONTAINER_NAME; parent.add_child(_root); _bodies.clear()
|
||||
for entry: Dictionary in BODIES: _build_body(entry)
|
||||
_stabilize_side_profile_leg_bends()
|
||||
for entry: Dictionary in JOINTS: _build_joint(entry)
|
||||
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
|
||||
if torso != null: torso.linear_velocity = _linear_velocity; torso.angular_velocity = _angular_velocity
|
||||
reset_rest_state()
|
||||
return torso != null
|
||||
|
||||
|
||||
## A walk keyframe can put a leg exactly straight. At that singularity the
|
||||
## physics solver has no geometric knee side to preserve, so floating-point
|
||||
## noise can make either knee fold as if the opposite facing profile were in
|
||||
## use. Give only near-straight side-profile legs a small world-space bend
|
||||
## toward the profile's established rear side before the joints are created.
|
||||
func _stabilize_side_profile_leg_bends() -> void:
|
||||
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
|
||||
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
|
||||
return
|
||||
var desired_delta := SIDE_PROFILE_KNEE_BIAS \
|
||||
if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT \
|
||||
else -SIDE_PROFILE_KNEE_BIAS
|
||||
for prefix: String in ["left", "right"]:
|
||||
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
|
||||
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
|
||||
if upper == null or lower == null:
|
||||
continue
|
||||
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
|
||||
if absf(current_delta) > SIDE_PROFILE_STRAIGHT_THRESHOLD:
|
||||
continue
|
||||
var upper_half := float(upper.get_meta("half_height", 0.0))
|
||||
var lower_half := float(lower.get_meta("half_height", 0.0))
|
||||
var upper_dir := Vector2.from_angle(upper.global_rotation)
|
||||
var knee := upper.global_position + upper_dir * upper_half
|
||||
var lower_dir := upper_dir.rotated(desired_delta)
|
||||
lower.global_rotation = lower_dir.angle()
|
||||
lower.global_position = knee + lower_dir * lower_half
|
||||
|
||||
|
||||
## PinJoint2D can cross its angular limit near a perfectly straight chain in
|
||||
## Godot 4.7.1. Correct only an actual side crossing so a knee cannot select
|
||||
## the opposite solution. Normal motion on the intended side remains physical.
|
||||
func _enforce_side_profile_leg_bends() -> void:
|
||||
if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \
|
||||
and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT):
|
||||
return
|
||||
var desired_side := -1.0 if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT else 1.0
|
||||
var desired_delta_sign := -desired_side
|
||||
for prefix: String in ["left", "right"]:
|
||||
var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D
|
||||
var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D
|
||||
if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower):
|
||||
continue
|
||||
var upper_half := float(upper.get_meta("half_height", 0.0))
|
||||
var lower_half := float(lower.get_meta("half_height", 0.0))
|
||||
var upper_dir := Vector2.from_angle(upper.global_rotation)
|
||||
var knee := upper.global_position + upper_dir * upper_half
|
||||
var hip := upper.global_position - upper_dir * upper_half
|
||||
var lower_dir_current := Vector2.from_angle(lower.global_rotation)
|
||||
var ankle := lower.global_position + lower_dir_current * lower_half
|
||||
var current_side := (ankle - hip).cross(knee - hip)
|
||||
if current_side * desired_side > 0.0:
|
||||
continue
|
||||
var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI)
|
||||
var corrected_delta := desired_delta_sign * minf(
|
||||
maxf(absf(current_delta), SIDE_PROFILE_KNEE_BIAS), deg_to_rad(150.0))
|
||||
var lower_dir := upper_dir.rotated(corrected_delta)
|
||||
lower.global_rotation = lower_dir.angle()
|
||||
lower.global_position = knee + lower_dir * lower_half
|
||||
lower.angular_velocity = upper.angular_velocity
|
||||
|
||||
func destroy() -> void:
|
||||
if _root != null and is_instance_valid(_root): _root.name = StringName(str(CONTAINER_NAME) + "_retired"); _root.queue_free()
|
||||
_root = null; _bodies.clear(); reset_rest_state()
|
||||
@@ -242,7 +317,10 @@ func snap_to_standing(previous_state: int) -> void:
|
||||
if _locomotion != null:
|
||||
_locomotion.clear_impact_reactions(true)
|
||||
_rig._hide_speech()
|
||||
_restore_standing_markers(false)
|
||||
# The factory captures the adapted skeleton's real authored spawn pose. That
|
||||
# is also the pose shown by the placement ghost, so every forced reset must
|
||||
# restore it instead of the legacy, one-size-fits-all STAND_POSE fallback.
|
||||
_restore_standing_markers(true)
|
||||
_rearm_ik_stack()
|
||||
_set_limb_mods_enabled(true)
|
||||
if previous_state == StickmanRig.RigState.ANIMATED:
|
||||
@@ -311,6 +389,9 @@ func initialize_spawn_pose_from_skeleton() -> void:
|
||||
func get_spawn_limb_rotations() -> Dictionary:
|
||||
return _spawn_limb_rotations
|
||||
|
||||
func get_spawn_pose() -> Dictionary:
|
||||
return _spawn_pose.duplicate(true)
|
||||
|
||||
func get_captured_pose() -> Dictionary:
|
||||
return _captured_pose
|
||||
|
||||
@@ -568,13 +649,10 @@ func _snap_skeleton_to_pose() -> void:
|
||||
var raw_head := _chain_angle_from_captured(float(head_pose.get("rot", 0.0))) - head_chain_offset - theta_t
|
||||
_head_bone.rotation = wrapf(_wrap_toward(raw_head, pre_head_rotation), -PI, PI)
|
||||
_recovery_bones.append({"bone": _head_bone, "target": _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)})
|
||||
if _body_container != null:
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
_rearm_ik_stack()
|
||||
_set_limb_mods_enabled(false)
|
||||
if _head_look_at != null:
|
||||
_head_look_at.enabled = false
|
||||
_rearm_ik_stack()
|
||||
for leg_joint: String in ["LeftLeg", "RightLeg"]:
|
||||
var modification := _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK
|
||||
if modification == null:
|
||||
@@ -598,6 +676,9 @@ func _snap_skeleton_to_pose() -> void:
|
||||
var captured_side := 1.0 if (ankle - hip).cross(knee - hip) >= 0.0 else -1.0
|
||||
modification.flip_bend_direction = bool(entry.get("flag", false)) if captured_side == float(entry.get("side", captured_side)) else not bool(entry.get("flag", false))
|
||||
modification.enabled = true
|
||||
if _body_container != null:
|
||||
_body_container.visible = true
|
||||
_body_container.modulate.a = 1.0
|
||||
|
||||
func _play_stand_up() -> void:
|
||||
_stand_up_generation += 1
|
||||
@@ -641,8 +722,8 @@ func _on_stand_up_finished(generation: int) -> void:
|
||||
_body_container.modulate.a = 1.0
|
||||
if _locomotion != null:
|
||||
_locomotion.set_collision_bodies_enabled(true)
|
||||
_rig._apply_idle_limb_solver_mode(recovered_rotations)
|
||||
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
|
||||
_rig._apply_idle_limb_solver_mode(recovered_rotations)
|
||||
|
||||
func _build_body(entry: Dictionary) -> void:
|
||||
var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key
|
||||
|
||||
+10
-10
@@ -479,9 +479,9 @@ func request_recovery() -> void:
|
||||
_ragdoll_component.request_recovery()
|
||||
|
||||
|
||||
## Instantly snaps the rig back to its authored standing pose — no stand-up
|
||||
## tween. Used by the sandbox stage so a stickman "reappears" at its starting
|
||||
## position/state on return to EDIT (instead of animating the recovery glide).
|
||||
## Instantly restores the rig's factory-captured spawn/ghost pose — no stand-up
|
||||
## tween. Used by the sandbox stage so a stickman reappears in its authored
|
||||
## starting pose when leaving PLAY (instead of using the generic fallback pose).
|
||||
func snap_to_standing() -> void:
|
||||
if _ragdoll_component != null:
|
||||
_ragdoll_component.snap_to_standing(int(state))
|
||||
@@ -620,7 +620,10 @@ func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void:
|
||||
and state == RigState.ANIMATED and not is_walking()
|
||||
var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations()
|
||||
_set_limb_mods_enabled(not pin_mirrored_idle)
|
||||
if not pin_mirrored_idle:
|
||||
# Recovery supplies the exact pre-ragdoll rotations. Write them immediately
|
||||
# even for profiles that keep IK enabled so the completion frame cannot show
|
||||
# the recovery-side knee solution while the solver waits for its next tick.
|
||||
if not pin_mirrored_idle and rotations.is_empty():
|
||||
return
|
||||
for joint: String in source:
|
||||
var bones: Dictionary = _limb_bones.get(joint, {})
|
||||
@@ -658,18 +661,15 @@ func _capture_limb_rotations() -> Dictionary:
|
||||
## Fix: when facing LEFT, disable the LookAt and pin the head bone to the
|
||||
## authored canonical local rotation (0), so the head is a rigid mirror of the
|
||||
## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted
|
||||
## every physics frame while the kinematic puppet is live (ANIMATED/RECOVERING)
|
||||
## because re-enabling the IK stack during recovery would otherwise let the
|
||||
## LookAt flip the bone again.
|
||||
## every physics frame while the kinematic puppet is ANIMATED; recovery disables
|
||||
## LookAt separately while its stand-up tween drives the head bone.
|
||||
func _apply_head_lookat_mirror_mode() -> void:
|
||||
if _head_look_at == null or not is_instance_valid(_head_look_at):
|
||||
return
|
||||
var mirrored := facing_profile == FacingProfile.LEFT
|
||||
_head_look_at.enabled = false#not mirrored
|
||||
_head_look_at.enabled = not mirrored
|
||||
if mirrored and _head_bone != null and is_instance_valid(_head_bone):
|
||||
_head_bone.rotation = 0.0
|
||||
elif not mirrored:
|
||||
_head_bone.rotation = 0
|
||||
|
||||
## Per-joint setter notify: updates the resolved TwoBoneIK mod's
|
||||
## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the
|
||||
|
||||
Reference in New Issue
Block a user