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:
@@ -183,6 +183,8 @@ func _test_reactions(rig: StickmanRig) -> void:
|
||||
|
||||
func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void:
|
||||
var authored: Dictionary = stage._authored[rig.get_instance_id()]
|
||||
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
|
||||
var authored_markers := ragdoll.get_spawn_pose()
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||
rig.global_position += Vector2(220.0, -90.0)
|
||||
@@ -201,9 +203,9 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi
|
||||
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile")
|
||||
for marker_name: String in StickmanRagdoll.STAND_POSE:
|
||||
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
||||
var expected: Dictionary = StickmanRagdoll.STAND_POSE[marker_name]
|
||||
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to standing after PLAY" % marker_name)
|
||||
_check(absf(angle_difference(torso_marker.rotation, float(StickmanRagdoll.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame")
|
||||
var expected: Dictionary = authored_markers[marker_name]
|
||||
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored spawn pose after PLAY" % marker_name)
|
||||
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "torso marker restores its authored rotation after PLAY")
|
||||
|
||||
|
||||
func _test_ghost_collision(stage: Node2D) -> void:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
extends SceneTree
|
||||
|
||||
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
||||
const BONE_PATHS: Array[String] = [
|
||||
"Skeleton2D/Torso/Head",
|
||||
"Skeleton2D/Torso/LeftUpperArm",
|
||||
"Skeleton2D/Torso/LeftUpperArm/LeftLowerArm",
|
||||
"Skeleton2D/Torso/RightUpperArm",
|
||||
"Skeleton2D/Torso/RightUpperArm/RightLowerArm",
|
||||
]
|
||||
const MARKER_NAMES: Array[String] = ["Head", "Left_Hand", "Right_Hand", "Torso"]
|
||||
const ROTATION_TOLERANCE := 0.0001
|
||||
const POSITION_TOLERANCE := 0.01
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var stage := STAGE_SCENE.instantiate() as Node2D
|
||||
root.add_child(stage)
|
||||
stage._snap_enabled = false
|
||||
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2.ZERO)
|
||||
stage._finalize_placed_node(rig)
|
||||
for frame in 3:
|
||||
await process_frame
|
||||
var pose_before := _capture_pose(rig)
|
||||
var markers_before := _capture_markers(rig)
|
||||
stage.set_mode(stage.StageMode.DIRECT)
|
||||
for frame in 3:
|
||||
await process_frame
|
||||
var pose_after := _capture_pose(rig)
|
||||
var markers_after := _capture_markers(rig)
|
||||
_check(stage.current_mode == stage.StageMode.DIRECT, "stage enters DIRECT")
|
||||
for path: String in BONE_PATHS:
|
||||
var before: Dictionary = pose_before[path]
|
||||
var after: Dictionary = pose_after[path]
|
||||
_check(absf(angle_difference(float(before["rotation"]), float(after["rotation"]))) <= ROTATION_TOLERANCE,
|
||||
"EDIT to DIRECT preserves %s rotation" % path.get_file())
|
||||
_check((before["position"] as Vector2).distance_to(after["position"] as Vector2) <= POSITION_TOLERANCE,
|
||||
"EDIT to DIRECT preserves %s position" % path.get_file())
|
||||
for marker_name: String in MARKER_NAMES:
|
||||
var before: Dictionary = markers_before[marker_name]
|
||||
var after: Dictionary = markers_after[marker_name]
|
||||
_check((before["position"] as Vector2).distance_to(after["position"] as Vector2) <= POSITION_TOLERANCE,
|
||||
"EDIT to DIRECT preserves %s IK marker" % marker_name)
|
||||
stage.set_mode(stage.StageMode.EDIT)
|
||||
for frame in 3:
|
||||
await process_frame
|
||||
var pose_round_trip := _capture_pose(rig)
|
||||
var markers_round_trip := _capture_markers(rig)
|
||||
_check(stage.current_mode == stage.StageMode.EDIT, "stage returns to EDIT")
|
||||
for path: String in BONE_PATHS:
|
||||
var before: Dictionary = pose_before[path]
|
||||
var round_trip: Dictionary = pose_round_trip[path]
|
||||
_check(absf(angle_difference(float(before["rotation"]), float(round_trip["rotation"]))) <= ROTATION_TOLERANCE,
|
||||
"EDIT to DIRECT to EDIT preserves %s rotation" % path.get_file())
|
||||
_check((before["position"] as Vector2).distance_to(round_trip["position"] as Vector2) <= POSITION_TOLERANCE,
|
||||
"EDIT to DIRECT to EDIT preserves %s position" % path.get_file())
|
||||
for marker_name: String in MARKER_NAMES:
|
||||
var before: Dictionary = markers_before[marker_name]
|
||||
var round_trip: Dictionary = markers_round_trip[marker_name]
|
||||
_check((before["position"] as Vector2).distance_to(round_trip["position"] as Vector2) <= POSITION_TOLERANCE,
|
||||
"EDIT to DIRECT to EDIT preserves %s IK marker" % marker_name)
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
for frame in 3:
|
||||
await physics_frame
|
||||
stage.set_mode(stage.StageMode.DIRECT)
|
||||
for frame in 4:
|
||||
await physics_frame
|
||||
var pose_after_play := _capture_pose(rig)
|
||||
var markers_after_play := _capture_markers(rig)
|
||||
_check(stage.current_mode == stage.StageMode.DIRECT, "stage enters DIRECT after PLAY")
|
||||
for path: String in BONE_PATHS:
|
||||
var before: Dictionary = pose_before[path]
|
||||
var after_play: Dictionary = pose_after_play[path]
|
||||
_check(absf(angle_difference(float(before["rotation"]), float(after_play["rotation"]))) <= ROTATION_TOLERANCE,
|
||||
"EDIT to PLAY to DIRECT restores %s rotation" % path.get_file())
|
||||
_check((before["position"] as Vector2).distance_to(after_play["position"] as Vector2) <= POSITION_TOLERANCE,
|
||||
"EDIT to PLAY to DIRECT restores %s position" % path.get_file())
|
||||
for marker_name: String in MARKER_NAMES:
|
||||
var before: Dictionary = markers_before[marker_name]
|
||||
var after_play: Dictionary = markers_after_play[marker_name]
|
||||
_check((before["position"] as Vector2).distance_to(after_play["position"] as Vector2) <= POSITION_TOLERANCE,
|
||||
"EDIT to PLAY to DIRECT restores %s IK marker" % marker_name)
|
||||
stage.queue_free()
|
||||
await process_frame
|
||||
if _failures == 0:
|
||||
print("RESULT: ALL PASSED (%d assertions)" % _checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
|
||||
quit(1)
|
||||
|
||||
|
||||
func _capture_pose(rig: StickmanRig) -> Dictionary:
|
||||
var result := {}
|
||||
for path: String in BONE_PATHS:
|
||||
var bone := rig.get_node(path) as Bone2D
|
||||
result[path] = {"rotation": bone.global_rotation, "position": bone.global_position}
|
||||
return result
|
||||
|
||||
|
||||
func _capture_markers(rig: StickmanRig) -> Dictionary:
|
||||
var result := {}
|
||||
for marker_name: String in MARKER_NAMES:
|
||||
var marker := rig.get_node("IK_Targets/" + marker_name) as Marker2D
|
||||
result[marker_name] = {"position": marker.position, "rotation": marker.rotation}
|
||||
return result
|
||||
|
||||
|
||||
func _check(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("FAIL: %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4q7x1m8n2vkp
|
||||
@@ -0,0 +1,90 @@
|
||||
extends SceneTree
|
||||
|
||||
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
||||
const WALK_PHASES: Array[float] = [0.0, 0.08, 0.16, 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, 0.72]
|
||||
const SETTLE_FRAMES := 30
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
for profile: int in [StickmanRig.FacingProfile.RIGHT, StickmanRig.FacingProfile.LEFT]:
|
||||
for walk_phase: float in WALK_PHASES:
|
||||
await _check_handoff(profile, walk_phase)
|
||||
if _failures == 0:
|
||||
print("RESULT: ALL PASSED (%d assertions)" % _checks)
|
||||
quit(0)
|
||||
else:
|
||||
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
|
||||
quit(1)
|
||||
|
||||
|
||||
func _check_handoff(profile: int, walk_phase: float) -> void:
|
||||
var stage := STAGE_SCENE.instantiate() as Node2D
|
||||
root.add_child(stage)
|
||||
stage._snap_enabled = false
|
||||
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2.ZERO)
|
||||
rig.auto_recover = false
|
||||
var destination_x := 500.0 if profile == StickmanRig.FacingProfile.RIGHT else -500.0
|
||||
rig.walk_to(Vector2(destination_x, 0.0))
|
||||
var animation := rig.get_node("AnimationPlayer") as AnimationPlayer
|
||||
animation.seek(walk_phase, true)
|
||||
await process_frame
|
||||
await physics_frame
|
||||
var label := "%s @ %.2fs" % [StickmanRig.FacingProfile.keys()[profile], walk_phase]
|
||||
_check(rig.get_facing_profile() == profile, "%s keeps its facing profile before ragdoll" % label)
|
||||
rig.set_ragdoll(true)
|
||||
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
|
||||
var expected_side := -1.0 if profile == StickmanRig.FacingProfile.RIGHT else 1.0
|
||||
_check(_both_legs_match(_ragdoll_leg_sides(ragdoll), expected_side),
|
||||
"%s enters ragdoll with both knees on the profile side" % label)
|
||||
for frame in SETTLE_FRAMES:
|
||||
await physics_frame
|
||||
await process_frame
|
||||
# SceneTree.process_frame is emitted before Node._process callbacks. Wait one
|
||||
# more idle frame so the rendered pose includes the post-physics knee guard.
|
||||
await process_frame
|
||||
var settled_sides := _ragdoll_leg_sides(ragdoll)
|
||||
_check(_both_legs_match(settled_sides, expected_side),
|
||||
"%s keeps both knees on the profile side for %d frames (sides %s)"
|
||||
% [label, SETTLE_FRAMES, str(settled_sides)])
|
||||
_check(rig.get_facing_profile() == profile, "%s never changes facing profile" % label)
|
||||
stage.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _ragdoll_leg_sides(ragdoll: StickmanRagdoll) -> Dictionary:
|
||||
var bodies := ragdoll.get_bodies()
|
||||
var result := {}
|
||||
for prefix: String in ["left", "right"]:
|
||||
var upper := bodies.get(prefix + "_upper_leg") as RigidBody2D
|
||||
var lower := bodies.get(prefix + "_lower_leg") as RigidBody2D
|
||||
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 lower_dir := Vector2.from_angle(lower.global_rotation)
|
||||
var hip := upper.global_position - upper_dir * upper_half
|
||||
var knee := upper.global_position + upper_dir * upper_half
|
||||
var ankle := lower.global_position + lower_dir * lower_half
|
||||
result["LeftLeg" if prefix == "left" else "RightLeg"] = \
|
||||
signf((ankle - hip).cross(knee - hip))
|
||||
return result
|
||||
|
||||
|
||||
func _both_legs_match(sides: Dictionary, expected_side: float) -> bool:
|
||||
return float(sides.get("LeftLeg", 0.0)) == expected_side \
|
||||
and float(sides.get("RightLeg", 0.0)) == expected_side
|
||||
|
||||
|
||||
func _check(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print("PASS: %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error("FAIL: %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsuireu51jue0
|
||||
@@ -32,6 +32,33 @@ func _run() -> void:
|
||||
_check(runner != null, "typed ActionRunner component resolves")
|
||||
_check(rig.get_locomotion() == locomotion, "root locomotion facade exposes authored component")
|
||||
|
||||
var head_look_at := rig._head_look_at as SkeletonModification2DLookAt
|
||||
var head_bone := rig.get_node("Skeleton2D/Torso/Head") as Bone2D
|
||||
var head_target := rig.get_node("IK_Targets/Head") as Marker2D
|
||||
_check(head_look_at != null and head_look_at.enabled,
|
||||
"FORWARD profile enables head marker tracking")
|
||||
var head_rotation_before := head_bone.rotation
|
||||
head_target.position += Vector2(-400.0, 180.0)
|
||||
for frame in 4:
|
||||
await process_frame
|
||||
_check(absf(wrapf(head_bone.rotation - head_rotation_before, -PI, PI)) > 0.1,
|
||||
"head bone follows a moved Head marker (before %.3f after %.3f target %s)"
|
||||
% [head_rotation_before, head_bone.rotation, str(head_target.global_position)])
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||
_check(not head_look_at.enabled, "LEFT mirror disables non-mirror-invariant head LookAt")
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT)
|
||||
_check(head_look_at.enabled, "RIGHT profile re-enables head marker tracking")
|
||||
|
||||
var left_bands: Dictionary = StickmanRagdoll.JOINT_BAND_LIMITS[StickmanRig.FacingProfile.LEFT]
|
||||
var right_bands: Dictionary = StickmanRagdoll.JOINT_BAND_LIMITS[StickmanRig.FacingProfile.RIGHT]
|
||||
for leg_key: String in ["left_lower_leg", "right_lower_leg"]:
|
||||
var left_band: Dictionary = left_bands[leg_key]
|
||||
var right_band: Dictionary = right_bands[leg_key]
|
||||
_check(float(left_band["lower"]) < 0.0 and float(left_band["upper"]) >= 0.0,
|
||||
"LEFT %s hinge includes straight and bends backward" % leg_key)
|
||||
_check(float(right_band["lower"]) <= 0.0 and float(right_band["upper"]) > 0.0,
|
||||
"RIGHT %s hinge includes straight and bends backward" % leg_key)
|
||||
|
||||
var assigned: Array[Dictionary] = [{"type": "wait", "duration": 0.25}]
|
||||
rig.action_queue = assigned
|
||||
_check(runner.action_queue.size() == 1, "direct root action_queue assignment delegates")
|
||||
|
||||
Reference in New Issue
Block a user