1013 lines
50 KiB
GDScript
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)
|