- Moved the rig scene from `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn`, preserving its UID. - Split the `StickmanRig` controller into three components: `StickmanLocomotion`, `StickmanRagdoll`, and `StickmanActionRunner`. - Updated the architecture to ensure `StickmanRig` acts as the public facade for the new components. - Created a new test suite `test_phase8_rig_modularization.gd` to verify the integrity of the relocated rig and its components. - Added checks for scene loading, component resolution, action queue delegation, and state authority.
127 lines
4.7 KiB
GDScript
127 lines
4.7 KiB
GDScript
# test_phase4b_walk.gd
|
|
# Headless regression checks for the Phase 4b walk-waypoint arrival-jitter fix
|
|
# (spec §3.4 / §5.9):
|
|
# 1. On-mesh waypoint: steering mode latches "nav" once, exactly one `arrived`
|
|
# fires, and the rig position is unchanged for 60+ physics frames after.
|
|
# 2. Off-mesh waypoint: mode latches "direct" once, one arrive, stable.
|
|
# 3. No vertical jitter: the root's final resting Y equals the snapped target.
|
|
#
|
|
# Run with:
|
|
# & "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --script res://tests/test_phase4b_walk.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")
|
|
const LOCOMOTION := preload("res://scripts/stickman_locomotion.gd")
|
|
|
|
const SETTLE_FRAMES := 60
|
|
const MAX_FRAMES := 300
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
var _arrived_count := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _on_arrived(_target: Vector2) -> void:
|
|
_arrived_count += 1
|
|
|
|
|
|
func _run() -> void:
|
|
print("")
|
|
print("========================================================")
|
|
print(" PHASE 4b WALK JITTER / LATCH TEST (headless)")
|
|
print("========================================================")
|
|
|
|
# On-mesh waypoint (x=50 inside the 200-wide ground slab).
|
|
await _walk_case("on-mesh", Vector2(50.0, 0.0), "nav")
|
|
# Off-mesh waypoint (x=200 outside the slab edge at x=100).
|
|
await _walk_case("off-mesh", Vector2(200.0, 0.0), "direct")
|
|
|
|
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)
|
|
|
|
|
|
func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> void:
|
|
print("")
|
|
print("--- %s waypoint (%s) ---" % [label, target_feet])
|
|
var stage: Node2D = STAGE_SCENE.instantiate()
|
|
root.add_child(stage)
|
|
var ground = stage._spawner.spawn("ground", Vector2(0, 0))
|
|
stage._rebake_navigation()
|
|
var rig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
|
_check(rig != null, "%s: stickman spawns" % label)
|
|
if rig == null:
|
|
stage.queue_free()
|
|
await process_frame
|
|
return
|
|
_arrived_count = 0
|
|
rig.arrived.connect(_on_arrived)
|
|
rig.walk_to(target_feet, 200.0)
|
|
var locomotion: StickmanLocomotion = rig.get_locomotion()
|
|
_check(locomotion != null, "%s: locomotion component is available" % label)
|
|
if locomotion == null:
|
|
stage.queue_free()
|
|
await process_frame
|
|
return
|
|
_check(not locomotion._walk_mode_latched, "%s: mode not latched before map sync" % label)
|
|
var frames := 0
|
|
while not locomotion._walk_done and frames < MAX_FRAMES:
|
|
await physics_frame
|
|
frames += 1
|
|
_check(locomotion._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames])
|
|
_check(_arrived_count == 1, "%s: exactly one arrived signal (got %d)" % [label, _arrived_count])
|
|
_check(locomotion._walk_mode_latched, "%s: mode is latched" % label)
|
|
_check(locomotion._walk_mode == expected_mode,
|
|
"%s: mode latched to '%s' (got '%s')" % [label, expected_mode, locomotion._walk_mode])
|
|
# Final position equals the snapped root target (feet target + FOOT_OFFSET).
|
|
var final_root: Vector2 = target_feet + LOCOMOTION.FOOT_OFFSET
|
|
_check(rig.global_position.distance_to(final_root) <= 0.01,
|
|
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
|
|
# Completion freezes the solved final animation frame; it must not be reset
|
|
# or drift while the root is at rest.
|
|
var torso = rig.get_node_or_null(NodePath("IK_Targets/Torso"))
|
|
var frozen_torso_position: Vector2 = Vector2.ZERO
|
|
if torso != null:
|
|
frozen_torso_position = (torso as Node2D).position
|
|
_check(frozen_torso_position.is_finite(), "%s: Torso final animation marker is finite" % label)
|
|
else:
|
|
_check(false, "%s: IK_Targets/Torso missing" % label)
|
|
# Post-arrival stability for 60 physics frames.
|
|
var p0: Vector2 = rig.global_position
|
|
var settled := true
|
|
for i in SETTLE_FRAMES:
|
|
await physics_frame
|
|
if rig.global_position != p0:
|
|
settled = false
|
|
break
|
|
_check(settled and rig.global_position == p0,
|
|
"%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES])
|
|
if torso != null:
|
|
_check((torso as Node2D).position.distance_to(frozen_torso_position) <= 0.01,
|
|
"%s: Torso final animation marker remains frozen" % label)
|
|
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
|
|
|
|
stage.queue_free()
|
|
await process_frame
|
|
|
|
|
|
func _check(condition: bool, message: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print("PASS: " + message)
|
|
else:
|
|
_failures += 1
|
|
print("FAIL: " + message)
|