Files
stickman/tests/test_phase3c_walk_recovery.gd

1013 lines
50 KiB
GDScript

# test_phase3c_walk_recovery.gd
# Headless regression suite for two Phase 3c.1 StickmanRig fixes:
#
# Fix 1 (whole-rig Y-axis mirror): facing LEFT is rendered as a whole-rig
# mirror -- `Master.scale.x = -1` (RIGHT/FORWARD -> (1,1)) -- replacing the
# old per-part/head mirroring. `_apply_head_flip()` and the
# `Body/Head.scale.x` mirror are REMOVED; the Head Pivot's driver transform
# is untouched (Pivot stays identity for every profile). Because the head's
# RemoteTransform2D driver (update_scale) and the SkeletonModification2DLookAt
# aim are not mirror-invariant, StickmanRig compensates:
# * The head driver pushes the FULL transform (update_scale, like every
# other Body/* driver), so Body/Head.scale stays identity-ish instead of
# canonicalizing to a Y-flip under the mirrored root.
# * When facing LEFT the head LookAt mod is disabled and the head bone is
# pinned to the FORWARD canonical aim rotation (PI), so the mounted head
# geometry (chin at the neck) is a rigid mirror of the FORWARD pose and
# cannot hang below the neck.
# * Root scale == (-1,1) for LEFT, (1,1) for RIGHT / FORWARD, toggling on
# profile switches.
# * Body/Head.scale stays identity-ish (never a manual mirror), Pivot stays
# identity, and the head geometry's torso-relative placement is rigid
# (its torso-local bbox center is identical across all three profiles).
# * While the canonical walk_right clip plays with facing LEFT (~50-frame
# sample), the root scale stays -1, Body/Head.scale does NOT oscillate
# frame-to-frame, its global rotation never wraps/jumps (> 0.5 rad), and
# the head placement does not drift (the old Y-flip bug class surfaced as
# a driver re-decomposition under the mirrored parent).
# * walk_to() always plays "walk_right" for BOTH directions and sets
# LEFT/RIGHT facing from dx; walk_left is never played at runtime.
# * Sweeping the Head bone under the LEFT mirror produces zero +/-PI
# discontinuities on Body/Head.global_rotation; under RIGHT (LookAt
# temporarily disabled by the test) the visual still tracks a swept head
# bone cleanly (driver path intact).
#
# Fix 2 (recovery re-anchor): _capture_ragdoll_pose() records the ragdoll
# torso's world landing anchor -- _captured_landing_center (world center)
# plus _captured_ground_y (center.y + RAGDOLL_TORSO_RADIUS, the ground
# contact line) -- and _start_recovery() calls _reanchor_root_to_landing(),
# which moves the rig root so the STANDING figure's FEET sit on the ground
# at the landing X (feet = (_captured_landing_center.x, _captured_ground_y),
# new_root = feet + FOOT_OFFSET), then re-bases the captured rig-local pose
# by the root shift. This replaces the old spine-direction-hip anchor (which
# grounded the hip, burying the standing feet when the torso lay flat).
# * Spawn the real ragdoll, then simulate a "landed" state by freezing all
# bodies and offsetting them from the rig root (documented teleport
# approach - a full gravity fall is too flaky headless). request_recovery()
# then runs the true capture -> re-anchor -> snap -> tween pipeline.
# * Upright-teleport case: the root moves to feet_world + FOOT_OFFSET (NOT
# the pre-ragdoll position and NOT the old hip-anchor root), the Torso IK
# marker starts on the captured (spine-direction) hip and RISES during the
# ~2.0s STAND_UP_DURATION tween to STAND_POSE.Torso.pos, and the state
# machine reports RECOVERING then ANIMATED after ~2.0s.
# * Lying case (torso teleported then rotated ~±90 degrees so the spine lies
# horizontal): the final feet end ON the ground at `_captured_ground_y`
# (not buried ~373px below it, as the old hip-anchor did), the Torso
# marker starts near the lying-pose hip and rises through the tween to
# STAND_POSE.Torso.pos, and the state stays RECOVERING until the end.
# * Fix 2 extension (runtime bug the user saw -- bones not following the
# markers during recovery): while the tween runs, the actual skeleton
# BONES (Skeleton2D/Torso + Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg)
# and a Body/* visual (Body/Body) MOVE with the IK markers (not frozen in
# the standing pose), and immediately after _snap_skeleton_to_pose the IK
# stack is enabled + is_setup() and the Skeleton2D is_processing_internal()
# (the defensive _rearm_ik_stack() re-setup).
#
# Run with:
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase3c_walk_recovery.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
## Physics frames per measurement block (head-mirror stability sample).
const SAMPLE_FRAMES := 50
## A single-frame wrapped rotation step above this (radians) is a mirror flip /
## wrap-jump event.
const FLIP_THRESHOLD_RAD := 0.5
## Head geometry torso-local bbox-center drift allowed across profiles / frames.
const PLACEMENT_TOL := 2.0
## Minimum mid-tween travel (px) proving a bone / Body visual actually moves
## with the IK markers (not frozen at the captured or standing pose).
const BONE_MOVE_MIN := 20.0
## Simulated "landed" ragdoll offset from the pre-ragdoll rig root.
const LANDING_DELTA := Vector2(600.0, 120.0)
## Recovery frame cap (2.0 s STAND_UP_DURATION needs ~120 frames @ 60 Hz).
const RECOVERY_MAX_FRAMES := 280
## Min physics frames the stand-up tween must take before ANIMATED (~1.33 s at
## 60 Hz) - proves a real ~2.0 s tween ran rather than an instant snap.
const RECOVERY_MIN_FRAMES := 80
## World-space tolerance for "feet on the ground" after a lying recovery.
const FEET_GROUND_TOLERANCE := 20.0
## Head-bone sweep targets (radians), stepped at 0.02 rad/frame.
const SWEEP_TARGETS: Array[float] = [-1.2, -0.6, 0.0, 0.6, 1.2, 0.6, 0.0, -0.6, -1.2]
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" PHASE 3c.1 WALK/RECOVERY REGRESSION TEST (headless)")
print("========================================================")
await _test_profile_root_mirror()
await _test_left_walk_right_stability()
await _test_walk_to_canonical_clip()
await _test_head_sweep_mirror_discriminator()
await _test_recovery_reanchor()
await _test_recovery_lying_reanchor()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Fix 1a: whole-rig Y-axis mirror -- root scale toggles, Body/Head stays
# identity-ish, Pivot stays identity, head placement is mirror-rigid.
# ---------------------------------------------------------------------------
func _test_profile_root_mirror() -> void:
print("")
print("--- Fix 1a: root mirror toggles; Body/Head identity; placement rigid ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the root-mirror test")
if rig == null:
await _free_stage(stage)
return
var pivot := rig.get_node_or_null(NodePath("Skeleton2D/Torso/Head/Pivot")) as Node2D
var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
_check(pivot != null, "Head Pivot node resolves")
_check(head_visual != null, "Body/Head visual node resolves")
if pivot == null or head_visual == null:
await _free_stage(stage)
return
# Let the RemoteTransform2D drivers settle the torso/head visuals once before
# capturing the FORWARD baseline placement (the authored scene pose differs
# from the first solved pose).
for i in 12:
await physics_frame
# FORWARD (default after _ready): root scale (1,1), no mirror anywhere.
_check(int(rig.get_facing_profile()) == int(rig.FacingProfile.FORWARD),
"fresh rig defaults to FORWARD (got %d)" % rig.get_facing_profile())
_check(_is_identity_scale(rig.scale),
"FORWARD: rig root scale is (1,1) (got %s)" % str(rig.scale))
_check(_is_identity_scale(head_visual.scale),
"FORWARD: Body/Head.scale is identity-ish (got %s)" % str(head_visual.scale))
_check(_is_identity_scale(pivot.scale) and is_zero_approx(pivot.rotation),
"FORWARD: Pivot stays identity (scale %s rot %.4f)" % [str(pivot.scale), pivot.rotation])
var baseline_center: Vector2 = _head_geom_torso_center(rig)
_check(baseline_center != Vector2.INF, "FORWARD: head geometry resolves for placement checks")
if baseline_center == Vector2.INF:
await _free_stage(stage)
return
# LEFT: the mirror must land on the RIG ROOT (-1,1), never on Body/Head.
rig.set_facing_profile(int(rig.FacingProfile.LEFT))
_check(int(rig.get_facing_profile()) == int(rig.FacingProfile.LEFT),
"profile switch to LEFT is stored")
_check(rig.scale.x < 0.0 and is_equal_approx(rig.scale.x, -1.0),
"LEFT: rig root scale.x == -1 (got %s)" % str(rig.scale))
_check(not _is_mirrored(head_visual.scale) and _is_identity_scale(head_visual.scale),
"LEFT: Body/Head.scale is NOT manually mirrored (identity-ish %s)" % str(head_visual.scale))
_check(_is_identity_scale(pivot.scale) and is_zero_approx(pivot.rotation),
"LEFT: Pivot stays identity (scale %s rot %.4f)" % [str(pivot.scale), pivot.rotation])
await physics_frame
_check(rig.scale.x < 0.0,
"LEFT after a frame: root mirror persists (got %s)" % str(rig.scale))
_check(_is_identity_scale(head_visual.scale) and not _is_mirrored(head_visual.scale),
"LEFT after a frame: Body/Head.scale stays identity-ish (got %s)" % str(head_visual.scale))
_check(_is_identity_scale(pivot.scale) and is_zero_approx(pivot.rotation),
"LEFT after a frame: Pivot stays identity (scale %s rot %.4f)"
% [str(pivot.scale), pivot.rotation])
_check(_head_geom_torso_center(rig).distance_to(baseline_center) <= PLACEMENT_TOL,
"LEFT: head geometry torso-local center matches FORWARD (delta %.2f px, center %s)"
% [_head_geom_torso_center(rig).distance_to(baseline_center), str(_head_geom_torso_center(rig))])
# RIGHT: root mirror clears to (1,1).
rig.set_facing_profile(int(rig.FacingProfile.RIGHT))
_check(_is_identity_scale(rig.scale),
"RIGHT: rig root scale is (1,1) (got %s)" % str(rig.scale))
_check(_is_identity_scale(head_visual.scale),
"RIGHT: Body/Head.scale is identity-ish (got %s)" % str(head_visual.scale))
_check(_is_identity_scale(pivot.scale) and is_zero_approx(pivot.rotation),
"RIGHT: Pivot stays identity (scale %s rot %.4f)" % [str(pivot.scale), pivot.rotation])
await physics_frame
_check(rig.scale.x > 0.0 and head_visual.scale.x > 0.0 and head_visual.scale.y > 0.0,
"RIGHT after a frame: root + Body/Head scales positive (root %s head %s)"
% [str(rig.scale), str(head_visual.scale)])
_check(_head_geom_torso_center(rig).distance_to(baseline_center) <= PLACEMENT_TOL,
"RIGHT: head geometry torso-local center matches FORWARD (delta %.2f px)"
% _head_geom_torso_center(rig).distance_to(baseline_center))
# FORWARD again: (1,1).
rig.set_facing_profile(int(rig.FacingProfile.FORWARD))
_check(_is_identity_scale(rig.scale),
"FORWARD (2nd): rig root scale is (1,1) (got %s)" % str(rig.scale))
_check(_is_identity_scale(head_visual.scale) and _is_identity_scale(pivot.scale),
"FORWARD (2nd): Body/Head + Pivot identity-ish")
await physics_frame
_check(_head_geom_torso_center(rig).distance_to(baseline_center) <= PLACEMENT_TOL,
"FORWARD (2nd): head placement still rigid (delta %.2f px)"
% _head_geom_torso_center(rig).distance_to(baseline_center))
# LEFT again: mirror re-engages on the ROOT.
rig.set_facing_profile(int(rig.FacingProfile.LEFT))
_check(rig.scale.x < 0.0,
"LEFT (2nd): rig root re-mirrors (got %s)" % str(rig.scale))
await physics_frame
_check(rig.scale.x < 0.0 and _is_identity_scale(head_visual.scale),
"LEFT (2nd) after a frame: root mirror active, Body/Head identity-ish (root %s head %s)"
% [str(rig.scale), str(head_visual.scale)])
_check(_head_geom_torso_center(rig).distance_to(baseline_center) <= PLACEMENT_TOL,
"LEFT (2nd): head placement still rigid (delta %.2f px)"
% _head_geom_torso_center(rig).distance_to(baseline_center))
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Fix 1b: walk_right playback stability under the root mirror (profile LEFT) --
# ~50-frame sample: root scale stays -1, Body/Head.scale never oscillates, the
# head visual never wrap-jumps, and the head placement stays rigid.
# ---------------------------------------------------------------------------
func _test_left_walk_right_stability() -> void:
print("")
print("--- Fix 1b: Body/Head stable while walk_right plays (profile LEFT) ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the walk-stability test")
if rig == null:
await _free_stage(stage)
return
var pivot := rig.get_node_or_null(NodePath("Skeleton2D/Torso/Head/Pivot")) as Node2D
var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
_check(pivot != null and head_visual != null and anim != null,
"Pivot / Body/Head / AnimationPlayer all resolve")
if pivot == null or head_visual == null or anim == null:
await _free_stage(stage)
return
# Face LEFT and play the canonical walk_right clip (a leftward walk in PLAY
# mode is exactly this: the rig root X-mirrors and walk_right plays mirrored).
rig.set_facing_profile(int(rig.FacingProfile.LEFT))
anim.play("walk_right")
_check(anim.is_playing() and anim.current_animation == "walk_right",
"walk_right animation is playing under facing LEFT (got '%s')" % anim.current_animation)
_check(rig.scale.x < 0.0,
"root scale is -1 while walk_right plays under LEFT (got %s)" % str(rig.scale))
# Warm up until the animation + driver settle.
for i in 12:
await physics_frame
_check(int(rig.get_facing_profile()) == int(rig.FacingProfile.LEFT),
"facing_profile is LEFT while walk_right plays (got %d)" % rig.get_facing_profile())
var baseline_scale: Vector2 = head_visual.scale
var placement_center: Vector2 = _head_geom_torso_center(rig)
var scale_mismatch := 0
var mirror_on_head := 0
var pivot_bad := 0
var profile_bad := 0
var root_mirror_off := 0
var flips := 0
var max_step := 0.0
var placement_drift := 0.0
var prev_rot := head_visual.global_rotation
for i in SAMPLE_FRAMES:
await physics_frame
if head_visual.scale != baseline_scale:
scale_mismatch += 1
if _is_mirrored(head_visual.scale):
mirror_on_head += 1
if not _is_identity_scale(pivot.scale) or not is_zero_approx(pivot.rotation):
pivot_bad += 1
if int(rig.get_facing_profile()) != int(rig.FacingProfile.LEFT):
profile_bad += 1
if rig.scale.x >= 0.0:
root_mirror_off += 1
var step := absf(wrapf(head_visual.global_rotation - prev_rot, -PI, PI))
max_step = maxf(max_step, step)
if step > FLIP_THRESHOLD_RAD:
flips += 1
prev_rot = head_visual.global_rotation
var c := _head_geom_torso_center(rig)
if c != Vector2.INF:
placement_drift = maxf(placement_drift, c.distance_to(placement_center))
_check(baseline_scale.x > 0.0 and baseline_scale.y > 0.0,
"walk_right LEFT: Body/Head baseline scale is identity-ish (got %s)" % str(baseline_scale))
_check(scale_mismatch == 0,
"walk_right LEFT: Body/Head.scale constant over %d frames (mismatches %d, scale %s)"
% [SAMPLE_FRAMES, scale_mismatch, str(head_visual.scale)])
_check(mirror_on_head == 0,
"walk_right LEFT: Body/Head never carries a mirror over %d frames" % SAMPLE_FRAMES)
_check(root_mirror_off == 0,
"walk_right LEFT: rig root mirror never drops over %d frames" % SAMPLE_FRAMES)
_check(pivot_bad == 0,
"walk_right LEFT: Pivot identity every sampled frame")
_check(profile_bad == 0,
"walk_right LEFT: facing_profile stays LEFT every sampled frame")
_check(flips == 0,
"walk_right LEFT: no +/-PI rotation wrap-jump over %d frames (flips %d)"
% [SAMPLE_FRAMES, flips])
_check(max_step <= FLIP_THRESHOLD_RAD,
"walk_right LEFT: max one-frame Body/Head rotation %.4f rad under %.2f"
% [max_step, FLIP_THRESHOLD_RAD])
_check(placement_drift <= PLACEMENT_TOL,
"walk_right LEFT: head placement drift over %d frames is %.2f px (<= %.1f)"
% [SAMPLE_FRAMES, placement_drift, PLACEMENT_TOL])
_check(anim.is_playing() and anim.current_animation == "walk_right",
"walk_right still playing at end of the sample")
_check(head_visual.scale == baseline_scale,
"walk_right LEFT: final Body/Head.scale equals baseline (%s)" % str(head_visual.scale))
_check(_is_identity_scale(pivot.scale) and is_zero_approx(pivot.rotation),
"walk_right LEFT: final Pivot identity (scale %s rot %.4f)" % [str(pivot.scale), pivot.rotation])
# RIGHT profile while playing walk_right: root unmirrored, still identity.
anim.stop()
rig.set_facing_profile(int(rig.FacingProfile.RIGHT))
anim.play("walk_right")
for i in 12:
await physics_frame
_check(rig.scale.x > 0.0 and not _is_mirrored(head_visual.scale) and head_visual.scale.x > 0.0,
"walk_right RIGHT: root unmirrored and Body/Head.scale positive (root %s head %s)"
% [str(rig.scale), str(head_visual.scale)])
_check(_is_identity_scale(pivot.scale) and is_zero_approx(pivot.rotation),
"walk_right RIGHT: Pivot still identity")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Fix 1c: walk_to() plays the canonical walk_right for both directions and sets
# LEFT / RIGHT facing from dx.
# ---------------------------------------------------------------------------
func _test_walk_to_canonical_clip() -> void:
print("")
print("--- Fix 1c: walk_to plays walk_right both directions; facing from dx ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the walk_to clip test")
if rig == null:
await _free_stage(stage)
return
var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
_check(anim != null, "AnimationPlayer resolves")
if anim == null:
await _free_stage(stage)
return
# A rightward target (dx > 0) must set RIGHT and play walk_right.
rig.walk_to(Vector2(500.0, 0.0))
_check(int(rig.get_facing_profile()) == int(rig.FacingProfile.RIGHT),
"walk_to rightward sets facing RIGHT (got %d)" % rig.get_facing_profile())
_check(rig.scale.x > 0.0, "walk_to rightward leaves the root unmirrored (scale %s)" % str(rig.scale))
_check(anim.current_animation == "walk_right",
"walk_to rightward plays the canonical walk_right (got '%s')" % anim.current_animation)
_check(anim.current_animation != "walk_left",
"walk_to rightward never plays walk_left")
# A leftward target (dx < 0) must set LEFT (root mirror) and STILL play
# walk_right (the same clip, X-mirrored by the root).
rig.walk_to(Vector2(-500.0, 0.0))
_check(int(rig.get_facing_profile()) == int(rig.FacingProfile.LEFT),
"walk_to leftward sets facing LEFT (got %d)" % rig.get_facing_profile())
_check(rig.scale.x < 0.0, "walk_to leftward root-mirrors the rig (scale %s)" % str(rig.scale))
_check(anim.current_animation == "walk_right",
"walk_to leftward STILL plays the canonical walk_right (got '%s')" % anim.current_animation)
_check(anim.current_animation != "walk_left",
"walk_to leftward never plays walk_left")
# Vertical-only target: keep the current facing (no dx), clip stays walk_right.
rig.walk_to(Vector2(0.0, 300.0))
_check(int(rig.get_facing_profile()) == int(rig.FacingProfile.LEFT),
"walk_to vertical keeps the existing LEFT facing (got %d)" % rig.get_facing_profile())
_check(anim.current_animation == "walk_right",
"walk_to vertical keeps playing walk_right (got '%s')" % anim.current_animation)
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Fix 1d: head-bone rotation sweep under the mirrored root.
# ---------------------------------------------------------------------------
# Headless note: the head LookAt DOES solve the head bone in this version (it
# writes PI at the FORWARD rest pose), so the sweep discriminator must account
# for it. Under LEFT the rig disables the LookAt and pins the bone to the
# FORWARD canonical aim, so external bone writes must NOT move the visual or
# produce wrap-jumps. Under RIGHT the test disables the LookAt itself and sweeps
# the bone to prove the RemoteTransform2D driver still tracks a rotating head
# bone cleanly (the old pivot-reflection bug class produced ~PI flips here).
func _test_head_sweep_mirror_discriminator() -> void:
print("")
print("--- Fix 1d: head-bone sweep under mirror + driver tracking ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the rotation sweep test")
if rig == null:
await _free_stage(stage)
return
var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
var head_bone := rig.get_node_or_null(NodePath("Skeleton2D/Torso/Head")) as Bone2D
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
_check(head_visual != null and head_bone != null and skeleton != null,
"Body/Head visual / Head bone / Skeleton2D resolve")
if head_visual == null or head_bone == null or skeleton == null:
await _free_stage(stage)
return
# --- LEFT: pinned mirror-rigid head. Sweeping the bone must not disturb it.
rig.set_facing_profile(int(rig.FacingProfile.LEFT))
await physics_frame
_check(_is_identity_scale(head_visual.scale),
"sweep setup LEFT: Body/Head.scale identity-ish (got %s)" % str(head_visual.scale))
var placement_center: Vector2 = _head_geom_torso_center(rig)
var flips := 0
var max_step := 0.0
var prev_rot := head_visual.global_rotation
for target: float in SWEEP_TARGETS:
for i in 30:
var current := head_bone.rotation
var next := move_toward(current, target, 0.02)
head_bone.rotation = next
await physics_frame
var step := absf(wrapf(head_visual.global_rotation - prev_rot, -PI, PI))
max_step = maxf(max_step, step)
if step > FLIP_THRESHOLD_RAD:
flips += 1
prev_rot = head_visual.global_rotation
if is_equal_approx(next, target):
break
_check(flips == 0,
"sweep under LEFT mirror: zero +/-PI wrap-jumps on the visual (got %d)" % flips)
_check(max_step <= FLIP_THRESHOLD_RAD,
"sweep under LEFT mirror: max one-frame visual rotation %.4f rad under %.2f"
% [max_step, FLIP_THRESHOLD_RAD])
_check(_head_geom_torso_center(rig).distance_to(placement_center) <= PLACEMENT_TOL,
"sweep under LEFT mirror: head placement stayed rigid (delta %.2f px)"
% _head_geom_torso_center(rig).distance_to(placement_center))
_check(_is_identity_scale(head_visual.scale) and not _is_mirrored(head_visual.scale),
"sweep under LEFT mirror: Body/Head.scale stays identity-ish (got %s)" % str(head_visual.scale))
# --- RIGHT: driver tracking of a manually-swept head bone (LookAt off for
# the duration). The visual must follow the bone with no wrap-jumps.
rig.set_facing_profile(int(rig.FacingProfile.RIGHT))
var look_at: SkeletonModification2DLookAt = null
var stack: SkeletonModificationStack2D = skeleton.modification_stack
if stack != null:
for i: int in stack.modification_count:
var mod = stack.get_modification(i)
if mod is SkeletonModification2DLookAt:
look_at = mod as SkeletonModification2DLookAt
break
_check(look_at != null, "head LookAt mod resolves for the RIGHT sweep")
if look_at == null:
await _free_stage(stage)
return
look_at.enabled = false
for i in 3:
await physics_frame
var flips2 := 0
var max_step2 := 0.0
var max_track_err := 0.0
prev_rot = head_visual.global_rotation
for target: float in SWEEP_TARGETS:
for i in 30:
var current := head_bone.rotation
var next := move_toward(current, target, 0.02)
head_bone.rotation = next
await physics_frame
var step := absf(wrapf(head_visual.global_rotation - prev_rot, -PI, PI))
max_step2 = maxf(max_step2, step)
if step > FLIP_THRESHOLD_RAD:
flips2 += 1
prev_rot = head_visual.global_rotation
max_track_err = maxf(max_track_err,
absf(wrapf(head_visual.global_rotation - head_bone.global_rotation, -PI, PI)))
if is_equal_approx(next, target):
break
_check(flips2 == 0,
"sweep under RIGHT: zero +/-PI wrap-jumps while the bone rotates (got %d)" % flips2)
_check(max_step2 <= FLIP_THRESHOLD_RAD,
"sweep under RIGHT: max one-frame visual rotation %.4f rad under %.2f"
% [max_step2, FLIP_THRESHOLD_RAD])
_check(max_track_err <= 0.02,
"sweep under RIGHT: Body/Head.global_rotation tracks the Head bone (max err %.4f rad)"
% max_track_err)
look_at.enabled = true
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Fix 2: recovery re-anchors the root so the FEET land on the ground at the
# ragdoll's landing spot (upright teleport case). Extended: bones + Body visual
# move with the markers during the tween, and the re-armed IK stack is
# enabled / setup / processing-internal right after the snap.
# ---------------------------------------------------------------------------
# Approach (documented): spawning the full ragdoll is real (set_ragdoll(true)
# builds the 10 RigidBody2D + PinJoint2D network), but a full gravity fall to
# rest is too flaky headless, so we simulate the "landed" state deterministically
# by freezing every body and teleporting the whole pile by LANDING_DELTA from the
# rig root. request_recovery() then runs the true _capture_ragdoll_pose ->
# _reanchor_root_to_landing -> _snap_skeleton_to_pose -> _play_stand_up pipeline.
func _test_recovery_reanchor() -> void:
print("")
print("--- Fix 2: recovery re-anchors feet onto the landing ground (upright) ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the recovery test")
if rig == null:
await _free_stage(stage)
return
var pre_root: Vector2 = rig.global_position
_check(pre_root.distance_to(Vector2(0, -385.0)) <= 0.5,
"pre-ragdoll root sits at the spawn feet+offset (got %s)" % str(pre_root))
rig.auto_recover = false
# Real ragdoll entry.
var state_events: Array = []
rig.state_changed.connect(func(s): state_events.append(int(s)))
rig.set_ragdoll(true)
_check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL")
_check(rig._ragdoll_bodies.size() == 10,
"ragdoll builds 10 bodies (got %d)" % rig._ragdoll_bodies.size())
var body_container := rig.get_node_or_null(NodePath("Body")) as Node2D
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
_check(body_container != null and not body_container.visible,
"Body/* kinematic puppet is hidden during RAGDOLL")
_check(skeleton != null and skeleton.modification_stack != null \
and not skeleton.modification_stack.enabled,
"IK stack is disabled during RAGDOLL")
var torso: RigidBody2D = rig._ragdoll_bodies.get("torso") as RigidBody2D
_check(torso != null, "ragdoll torso body exists")
if torso == null:
await _free_stage(stage)
return
_check(float(torso.get_meta("half_height", 0.0)) > 100.0,
"torso capsule carries a half_height meta for hip derivation (%.1f)"
% float(torso.get_meta("half_height", 0.0)))
# Simulate "landed": freeze every body, then offset the whole pile.
for key: String in rig._ragdoll_bodies:
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D
b.freeze = true
b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
var torso_before: Vector2 = torso.global_position
for key: String in rig._ragdoll_bodies:
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D
b.global_position += LANDING_DELTA
for i in 3:
await physics_frame
_check(torso.global_position.distance_to(torso_before + LANDING_DELTA) <= 0.5,
"landing teleport moved the torso by the full delta (got %s)"
% str(torso.global_position - torso_before))
# Expected landing anchor (NEW feet-based math).
var half := float(torso.get_meta("half_height", 0.0))
var spine_dir := Vector2.from_angle(torso.global_rotation)
var hip_world: Vector2 = torso.global_position - spine_dir * half
var landing_center: Vector2 = torso.global_position
var ground_y: float = torso.global_position.y + float(RIG.RAGDOLL_TORSO_RADIUS)
var feet_world := Vector2(landing_center.x, ground_y)
var expected_root: Vector2 = feet_world + (RIG.FOOT_OFFSET as Vector2)
var stand_torso: Vector2 = (RIG.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO)
var final_hip_world: Vector2 = expected_root + stand_torso
var old_hip_anchor_root: Vector2 = hip_world - stand_torso
_check(hip_world.distance_to(pre_root) > 400.0,
"landing hip is far from the pre-ragdoll root (delta %.1f px)"
% hip_world.distance_to(pre_root))
# Recovery: capture -> re-anchor -> snap -> stand-up tween.
rig.request_recovery()
_check(not rig.is_in_ragdoll() and int(rig.state) == int(rig.RigState.RECOVERING),
"request_recovery() enters RECOVERING (state %d)" % int(rig.state))
_check(not state_events.is_empty() and state_events[-1] == int(rig.RigState.RECOVERING),
"state_changed emitted RECOVERING (events %s)" % str(state_events))
_check(rig._captured_landing_center.distance_to(landing_center) <= 0.5,
"_captured_landing_center matches the simulated landing center (got %s)"
% str(rig._captured_landing_center))
_check(absf(rig._captured_ground_y - ground_y) <= 0.5,
"_captured_ground_y matches center.y + torso radius (got %.2f, expected %.2f)"
% [rig._captured_ground_y, ground_y])
_check(rig.global_position.distance_to(expected_root) <= 0.5,
"root re-anchored so the feet sit on the landing ground (got %s, expected %s)"
% [str(rig.global_position), str(expected_root)])
_check(rig.global_position.distance_to(old_hip_anchor_root) > 100.0,
"root is NOT at the old hip-anchor root (got %s, old %s, delta %.1f px)"
% [str(rig.global_position), str(old_hip_anchor_root),
rig.global_position.distance_to(old_hip_anchor_root)])
_check(rig.global_position.distance_to(pre_root) > 400.0,
"root moved away from the pre-ragdoll position (%.1f px)"
% rig.global_position.distance_to(pre_root))
# _snap_skeleton_to_pose just ran -> the defensive _rearm_ik_stack() must
# have re-enabled + re-setup the stack and re-armed Skeleton2D internal
# processing (the exact runtime gap the user saw -- bones frozen while the
# markers tweened).
_check(skeleton != null and skeleton.modification_stack != null \
and skeleton.modification_stack.enabled,
"IK stack is enabled right after _snap_skeleton_to_pose")
_check(skeleton != null and skeleton.modification_stack != null \
and skeleton.modification_stack.get_is_setup(),
"IK stack get_is_setup() is true right after _rearm_ik_stack()")
_check(skeleton != null and skeleton.is_processing_internal(),
"Skeleton2D is_processing_internal() after _rearm_ik_stack()")
# Track the real skeleton bones + a Body visual through the tween so we can
# prove they MOVE with the markers (not frozen in the standing pose).
var torso_bone := rig.get_node_or_null(NodePath("Skeleton2D/Torso")) as Node2D
var left_lower_leg_bone := rig.get_node_or_null(
NodePath("Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg")) as Node2D
var torso_visual := rig.get_node_or_null(NodePath("Body/Body")) as Node2D
_check(torso_bone != null and left_lower_leg_bone != null and torso_visual != null,
"Torso bone / LeftLowerLeg bone / Body/Body visual resolve for the follow check")
var torso_bone_start: Vector2 = torso_bone.global_position if torso_bone != null else Vector2.ZERO
var limb_bone_start: Vector2 = left_lower_leg_bone.global_position if left_lower_leg_bone != null else Vector2.ZERO
var torso_visual_start: Vector2 = torso_visual.global_position if torso_visual != null else Vector2.ZERO
var torso_marker := rig.get_node_or_null(NodePath("IK_Targets/Torso")) as Marker2D
_check(torso_marker != null, "IK_Targets/Torso marker resolves")
if torso_marker == null:
await _free_stage(stage)
return
_check(torso_marker.global_position.distance_to(hip_world) <= 2.0,
"Torso marker world position starts on the captured hip (got %s, hip %s)"
% [str(torso_marker.global_position), str(hip_world)])
_check(torso_marker.global_position.distance_to(final_hip_world) > 100.0,
"Torso marker does NOT start at the standing-pose hip (delta %.1f px)"
% torso_marker.global_position.distance_to(final_hip_world))
# Ride the stand-up tween to completion, sampling every physics frame.
var root_stayed := true
var frames := 0
var marker_first_world: Vector2 = torso_marker.global_position
var marker_mid_world := marker_first_world
var state_mid := -1
var state_late := -1
# Mid-tween bone / visual samples (the movement-proof for the runtime bug).
var bone_moved := false
var limb_bone_moved := false
var visual_moved := false
while int(rig.state) != int(rig.RigState.ANIMATED) and frames < RECOVERY_MAX_FRAMES:
await physics_frame
frames += 1
if rig.global_position.distance_to(expected_root) > 0.5:
root_stayed = false
if frames == 60:
marker_mid_world = torso_marker.global_position
state_mid = int(rig.state)
if torso_bone != null and torso_bone_start.distance_to(torso_bone.global_position) > BONE_MOVE_MIN:
bone_moved = true
if left_lower_leg_bone != null and limb_bone_start.distance_to(left_lower_leg_bone.global_position) > BONE_MOVE_MIN:
limb_bone_moved = true
if torso_visual != null and torso_visual_start.distance_to(torso_visual.global_position) > BONE_MOVE_MIN:
visual_moved = true
if frames == 90:
state_late = int(rig.state)
_check(frames < RECOVERY_MAX_FRAMES,
"stand-up tween completes within %d frames (took %d)" % [RECOVERY_MAX_FRAMES, frames])
_check(frames >= RECOVERY_MIN_FRAMES,
"stand-up tween took >= %d frames (took %d) - a real ~2.0s tween ran"
% [RECOVERY_MIN_FRAMES, frames])
_check(root_stayed,
"root stays on the landing ground for the entire tween (no slide-back)")
_check(state_mid == int(rig.RigState.RECOVERING),
"state is still RECOVERING at frame 60 (mid-tween, got %d)" % state_mid)
_check(state_late == int(rig.RigState.RECOVERING),
"state is still RECOVERING at frame 90 (late-tween, got %d)" % state_late)
_check(marker_mid_world.y < marker_first_world.y - 20.0,
"Torso marker world y RISES during the tween (first %.1f -> mid %.1f)"
% [marker_first_world.y, marker_mid_world.y])
_check(marker_mid_world.y > final_hip_world.y + 20.0,
"Torso marker is still above the ground-bound hip mid-tween (mid %.1f, final %.1f)"
% [marker_mid_world.y, final_hip_world.y])
_check(bone_moved,
"Torso bone MOVES with the marker mid-tween (start %s -> frame-60 pos %s, > %.0f px)"
% [str(torso_bone_start), str(torso_bone.global_position if torso_bone != null else Vector2.ZERO), BONE_MOVE_MIN])
_check(limb_bone_moved,
"LeftLowerLeg bone MOVES with the markers mid-tween (start %s, > %.0f px)"
% [str(limb_bone_start), BONE_MOVE_MIN])
_check(visual_moved,
"Body/Body visual MOVES with the markers mid-tween (start %s, > %.0f px)"
% [str(torso_visual_start), BONE_MOVE_MIN])
_check(int(rig.state) == int(rig.RigState.ANIMATED),
"recovery ends in ANIMATED (state %d)" % int(rig.state))
_check(state_events.size() >= 2 and state_events[-1] == int(rig.RigState.ANIMATED),
"state_changed emitted ANIMATED on completion (events %s)" % str(state_events))
_check(rig.global_position.distance_to(expected_root) <= 0.5,
"final root still equals the landing ground root (got %s)" % str(rig.global_position))
_check(rig.global_position.distance_to(pre_root) > 400.0,
"final root is NOT back at the pre-ragdoll position")
_check(torso_marker.position.distance_to(stand_torso) <= 0.5,
"Torso marker settles exactly on STAND_POSE (got %s)" % str(torso_marker.position))
_check(torso_marker.global_position.distance_to(final_hip_world) <= 1.0,
"figure hip stands over the grounded feet in world space (got %s, expected %s)"
% [str(torso_marker.global_position), str(final_hip_world)])
_check(torso_marker.global_position.distance_to(hip_world) > 100.0,
"final hip is NOT at the captured lying hip (delta %.1f px)"
% torso_marker.global_position.distance_to(hip_world))
# The standing figure's feet (leg IK markers at STAND_POSE) must sit on the
# ground line at the landing X -- not buried below it.
var left_leg := rig.get_node_or_null(NodePath("IK_Targets/Left_Leg")) as Marker2D
var right_leg := rig.get_node_or_null(NodePath("IK_Targets/Right_Leg")) as Marker2D
if left_leg != null and right_leg != null:
_check(absf((left_leg.global_position.y + right_leg.global_position.y) * 0.5 - ground_y) <= FEET_GROUND_TOLERANCE,
"feet (leg markers) end on the ground line (avg y %.1f, ground %.1f)"
% [(left_leg.global_position.y + right_leg.global_position.y) * 0.5, ground_y])
_check(absf((left_leg.global_position.x + right_leg.global_position.x) * 0.5 - landing_center.x) <= FEET_GROUND_TOLERANCE,
"feet end centered on the landing X (avg x %.1f, landing x %.1f)"
% [(left_leg.global_position.x + right_leg.global_position.x) * 0.5, landing_center.x])
_check(body_container != null and body_container.visible,
"Body/* puppet is visible again after recovery")
_check(skeleton != null and skeleton.modification_stack != null \
and skeleton.modification_stack.enabled,
"IK stack is re-enabled after recovery")
# Interrupt sanity: set_ragdoll(true) during ANIMATED re-enters RAGDOLL
# cleanly (idempotent start of another cycle).
rig.set_ragdoll(true)
_check(rig.is_in_ragdoll(), "post-recovery set_ragdoll(true) re-enters RAGDOLL")
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Fix 2: recovery from a TRUE LYING ragdoll (torso rotated ~±90 degrees so the
# spine is horizontal). Same re-anchor assertions as the upright case, plus the
# bones/visual-follow proof and the re-armed-stack checks.
# ---------------------------------------------------------------------------
func _test_recovery_lying_reanchor() -> void:
print("")
print("--- Fix 2: recovery re-anchors feet onto the ground (lying ragdoll) ---")
var cases: Array[Dictionary] = [
{ "label": "lying +90 deg (spine horizontal, head right)", "torso_rotation": 0.0 },
{ "label": "lying -90 deg (spine horizontal, head left)", "torso_rotation": PI },
]
for c: Dictionary in cases:
await _run_lying_recovery_case(
String(c["label"]), float(c["torso_rotation"]))
func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
print(" case: %s" % label)
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the lying recovery case")
if rig == null:
await _free_stage(stage)
return
rig.auto_recover = false
var pre_root: Vector2 = rig.global_position
var state_events: Array = []
rig.state_changed.connect(func(s): state_events.append(int(s)))
rig.set_ragdoll(true)
_check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL")
var torso: RigidBody2D = rig._ragdoll_bodies.get("torso") as RigidBody2D
_check(torso != null, "ragdoll torso body exists")
if torso == null:
await _free_stage(stage)
return
# Freeze + teleport the whole pile, then rotate ONLY the torso body so its
# spine lies ~horizontal (a true lying pose ~±90 deg from the standing -90).
for key: String in rig._ragdoll_bodies:
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D
b.freeze = true
b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
b.global_position += LANDING_DELTA
torso.rotation = torso_rotation
for i in 3:
await physics_frame
_check(absf(wrapf(torso.global_rotation - torso_rotation, -PI, PI)) <= 0.01,
"lying torso rotation is applied (got %.3f rad)" % torso.global_rotation)
# Expected landing anchor (NEW feet-based math).
var half := float(torso.get_meta("half_height", 0.0))
var spine_dir := Vector2.from_angle(torso.global_rotation)
var hip_world: Vector2 = torso.global_position - spine_dir * half
var landing_center: Vector2 = torso.global_position
var ground_y: float = torso.global_position.y + float(RIG.RAGDOLL_TORSO_RADIUS)
var feet_world := Vector2(landing_center.x, ground_y)
var expected_root: Vector2 = feet_world + (RIG.FOOT_OFFSET as Vector2)
var stand_torso: Vector2 = (RIG.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO)
var final_hip_world: Vector2 = expected_root + stand_torso
var old_hip_anchor_root: Vector2 = hip_world - stand_torso
_check(absf(hip_world.y - ground_y) <= float(RIG.RAGDOLL_TORSO_RADIUS) + 1.0,
"lying hip sits at the body's mid-height, near (not above) the ground line")
_check(old_hip_anchor_root.distance_to(expected_root) > 300.0,
"old hip-anchor root differs wildly from the new feet-anchor root (%.1f px)"
% old_hip_anchor_root.distance_to(expected_root))
# Recovery: capture -> re-anchor -> snap -> stand-up tween.
rig.request_recovery()
_check(not rig.is_in_ragdoll() and int(rig.state) == int(rig.RigState.RECOVERING),
"request_recovery() enters RECOVERING (state %d)" % int(rig.state))
_check(rig._captured_landing_center.distance_to(landing_center) <= 0.5,
"_captured_landing_center matches the lying torso center (got %s)"
% str(rig._captured_landing_center))
_check(absf(rig._captured_ground_y - ground_y) <= 0.5,
"_captured_ground_y matches the lying ground-contact line (got %.2f, expected %.2f)"
% [rig._captured_ground_y, ground_y])
_check(rig.global_position.distance_to(expected_root) <= 0.5,
"root re-anchored so the standing feet sit on the lying ground (got %s, expected %s)"
% [str(rig.global_position), str(expected_root)])
_check(rig.global_position.distance_to(old_hip_anchor_root) > 300.0,
"root is NOT at the old hip-anchor root (which would bury the feet)")
_check(rig.global_position.distance_to(pre_root) > 400.0,
"root moved away from the pre-ragdoll position (%.1f px)"
% rig.global_position.distance_to(pre_root))
# _snap_skeleton_to_pose just ran -> re-armed stack state.
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
_check(skeleton != null and skeleton.modification_stack != null \
and skeleton.modification_stack.enabled,
"lying: IK stack is enabled right after _snap_skeleton_to_pose")
_check(skeleton != null and skeleton.modification_stack != null \
and skeleton.modification_stack.get_is_setup(),
"lying: IK stack get_is_setup() is true right after _rearm_ik_stack()")
_check(skeleton != null and skeleton.is_processing_internal(),
"lying: Skeleton2D is_processing_internal() after _rearm_ik_stack()")
var torso_bone := rig.get_node_or_null(NodePath("Skeleton2D/Torso")) as Node2D
var left_lower_leg_bone := rig.get_node_or_null(
NodePath("Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg")) as Node2D
var torso_visual := rig.get_node_or_null(NodePath("Body/Body")) as Node2D
var torso_bone_start: Vector2 = torso_bone.global_position if torso_bone != null else Vector2.ZERO
var limb_bone_start: Vector2 = left_lower_leg_bone.global_position if left_lower_leg_bone != null else Vector2.ZERO
var torso_visual_start: Vector2 = torso_visual.global_position if torso_visual != null else Vector2.ZERO
var torso_marker := rig.get_node_or_null(NodePath("IK_Targets/Torso")) as Marker2D
_check(torso_marker != null, "IK_Targets/Torso marker resolves")
if torso_marker == null:
await _free_stage(stage)
return
_check(torso_marker.global_position.distance_to(hip_world) <= 2.0,
"Torso marker world position starts on the lying-pose hip (got %s, hip %s)"
% [str(torso_marker.global_position), str(hip_world)])
# Ride the tween. The marker must rise from the lying hip to the standing
# hip, state stays RECOVERING, and the feet must not be buried.
var root_stayed := true
var frames := 0
var marker_first_world: Vector2 = torso_marker.global_position
var marker_mid_world := marker_first_world
var state_mid := -1
var state_late := -1
var bone_moved := false
var limb_bone_moved := false
var visual_moved := false
while int(rig.state) != int(rig.RigState.ANIMATED) and frames < RECOVERY_MAX_FRAMES:
await physics_frame
frames += 1
if rig.global_position.distance_to(expected_root) > 0.5:
root_stayed = false
if frames == 60:
marker_mid_world = torso_marker.global_position
state_mid = int(rig.state)
if torso_bone != null and torso_bone_start.distance_to(torso_bone.global_position) > BONE_MOVE_MIN:
bone_moved = true
if left_lower_leg_bone != null and limb_bone_start.distance_to(left_lower_leg_bone.global_position) > BONE_MOVE_MIN:
limb_bone_moved = true
if torso_visual != null and torso_visual_start.distance_to(torso_visual.global_position) > BONE_MOVE_MIN:
visual_moved = true
if frames == 90:
state_late = int(rig.state)
_check(frames < RECOVERY_MAX_FRAMES,
"stand-up tween completes within %d frames (took %d)" % [RECOVERY_MAX_FRAMES, frames])
_check(frames >= RECOVERY_MIN_FRAMES,
"stand-up tween took >= %d frames (took %d) - a real ~2.0s tween ran"
% [RECOVERY_MIN_FRAMES, frames])
_check(root_stayed,
"root stays on the landing ground for the entire tween (no slide-back)")
_check(state_mid == int(rig.RigState.RECOVERING),
"state is still RECOVERING at frame 60 (mid-tween, got %d)" % state_mid)
_check(state_late == int(rig.RigState.RECOVERING),
"state is still RECOVERING at frame 90 (late-tween, got %d)" % state_late)
_check(marker_mid_world.y < marker_first_world.y - 20.0,
"Torso marker world y RISES during the tween (first %.1f -> mid %.1f)"
% [marker_first_world.y, marker_mid_world.y])
_check(marker_mid_world.y > final_hip_world.y + 20.0,
"Torso marker is still mid-rise at frame 60 (mid %.1f, final %.1f)"
% [marker_mid_world.y, final_hip_world.y])
_check(bone_moved,
"lying: Torso bone MOVES with the marker mid-tween (> %.0f px)" % BONE_MOVE_MIN)
_check(limb_bone_moved,
"lying: LeftLowerLeg bone MOVES with the markers mid-tween (> %.0f px)" % BONE_MOVE_MIN)
_check(visual_moved,
"lying: Body/Body visual MOVES with the markers mid-tween (> %.0f px)" % BONE_MOVE_MIN)
_check(int(rig.state) == int(rig.RigState.ANIMATED),
"recovery ends in ANIMATED (state %d)" % int(rig.state))
_check(state_events.size() >= 2 and state_events[-1] == int(rig.RigState.ANIMATED),
"state_changed emitted ANIMATED only at the end (events %s)" % str(state_events))
_check(rig.global_position.distance_to(expected_root) <= 0.5,
"final root still equals the landing ground root (got %s)" % str(rig.global_position))
_check(torso_marker.position.distance_to(stand_torso) <= 0.5,
"Torso marker settles exactly on STAND_POSE (got %s)" % str(torso_marker.position))
_check(torso_marker.global_position.distance_to(final_hip_world) <= 1.0,
"figure hip stands over the grounded feet in world space (got %s, expected %s)"
% [str(torso_marker.global_position), str(final_hip_world)])
# The KEY lying regression: feet end ON the ground (not buried ~373px under
# it) and centered at the landing X.
var left_leg := rig.get_node_or_null(NodePath("IK_Targets/Left_Leg")) as Marker2D
var right_leg := rig.get_node_or_null(NodePath("IK_Targets/Right_Leg")) as Marker2D
if left_leg != null and right_leg != null:
var feet_avg_y: float = (left_leg.global_position.y + right_leg.global_position.y) * 0.5
var feet_avg_x: float = (left_leg.global_position.x + right_leg.global_position.x) * 0.5
_check(feet_avg_y <= ground_y + FEET_GROUND_TOLERANCE,
"feet are NOT buried below the ground line (avg y %.1f <= ground %.1f + tol)"
% [feet_avg_y, ground_y])
_check(feet_avg_y >= ground_y - FEET_GROUND_TOLERANCE,
"feet are not floating above the ground line (avg y %.1f >= ground %.1f - tol)"
% [feet_avg_y, ground_y])
_check(absf(feet_avg_x - landing_center.x) <= FEET_GROUND_TOLERANCE,
"feet end at the landing X (avg x %.1f, landing x %.1f)"
% [feet_avg_x, landing_center.x])
await _free_stage(stage)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
func _new_stage() -> Node2D:
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage._snap_enabled = false
return stage
func _free_stage(stage: Node2D) -> void:
stage.queue_free()
await process_frame
## A negative-determinant scale (x*y < 0) means the node carries an X mirror.
func _is_mirrored(scale: Vector2) -> bool:
return scale.x * scale.y < 0.0
func _is_identity_scale(scale: Vector2) -> bool:
return is_equal_approx(scale.x, 1.0) and is_equal_approx(scale.y, 1.0)
## Bbox center of the mounted Body/Head geometry expressed in the Body/Body
## (torso visual) local frame. Under a whole-rig mirror this frame is itself
## mirrored, so a rigid mirror leaves the value unchanged -- a great invariant
## for the "head AND body mirror together" requirement. Vector2.INF when no
## geometry or the torso visual is missing.
func _head_geom_torso_center(rig: Node2D) -> Vector2:
var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
var torso_visual := rig.get_node_or_null(NodePath("Body/Body")) as Node2D
if head_visual == null or torso_visual == null:
return Vector2.INF
var min_x := INF
var min_y := INF
var max_x := -INF
var max_y := -INF
var found := false
for child: Node in head_visual.get_children():
if child is Polygon2D:
for p: Vector2 in (child as Polygon2D).polygon:
var lp := torso_visual.to_local((child as Polygon2D).to_global(p))
min_x = minf(min_x, lp.x); min_y = minf(min_y, lp.y)
max_x = maxf(max_x, lp.x); max_y = maxf(max_y, lp.y)
found = true
elif child is Line2D:
for p: Vector2 in (child as Line2D).points:
var lp := torso_visual.to_local((child as Line2D).to_global(p))
min_x = minf(min_x, lp.x); min_y = minf(min_y, lp.y)
max_x = maxf(max_x, lp.x); max_y = maxf(max_y, lp.y)
found = true
if not found:
return Vector2.INF
return Vector2((min_x + max_x) * 0.5, (min_y + max_y) * 0.5)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)