# 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)