Implement Phase 8: Rig Relocation and Component Modularization

- 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.
This commit is contained in:
2026-09-18 14:11:53 -04:00
parent 9a22322dba
commit db4ff8d939
29 changed files with 2820 additions and 2747 deletions
+30 -26
View File
@@ -34,7 +34,7 @@
#
# 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
# plus _captured_ground_y (center.y + StickmanRagdoll.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),
@@ -544,8 +544,10 @@ func _test_recovery_reanchor() -> void:
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 ragdoll: StickmanRagdoll = rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
var ragdoll_bodies: Dictionary = ragdoll.get_bodies()
_check(ragdoll_bodies.size() == 10,
"ragdoll builds 10 bodies (got %d)" % 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,
@@ -553,7 +555,7 @@ func _test_recovery_reanchor() -> void:
_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
var torso: RigidBody2D = ragdoll_bodies.get("torso") as RigidBody2D
_check(torso != null, "ragdoll torso body exists")
if torso == null:
await _free_stage(stage)
@@ -563,13 +565,13 @@ func _test_recovery_reanchor() -> void:
% 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
for key: String in ragdoll_bodies:
var b: RigidBody2D = 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
for key: String in ragdoll_bodies:
var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D
b.global_position += LANDING_DELTA
for i in 3:
await physics_frame
@@ -582,10 +584,10 @@ func _test_recovery_reanchor() -> void:
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 ground_y: float = torso.global_position.y + StickmanRagdoll.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 expected_root: Vector2 = feet_world + StickmanLocomotion.FOOT_OFFSET
var stand_torso: Vector2 = (StickmanRagdoll.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,
@@ -598,12 +600,12 @@ func _test_recovery_reanchor() -> void:
"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,
_check(ragdoll._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,
% str(ragdoll._captured_landing_center))
_check(absf(ragdoll._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])
% [ragdoll._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)])
@@ -779,7 +781,9 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
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
var ragdoll: StickmanRagdoll = rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
var ragdoll_bodies: Dictionary = ragdoll.get_bodies()
var torso: RigidBody2D = ragdoll_bodies.get("torso") as RigidBody2D
_check(torso != null, "ragdoll torso body exists")
if torso == null:
await _free_stage(stage)
@@ -787,8 +791,8 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
# 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
for key: String in ragdoll_bodies:
var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D
b.freeze = true
b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
b.global_position += LANDING_DELTA
@@ -804,13 +808,13 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
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 ground_y: float = torso.global_position.y + StickmanRagdoll.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 expected_root: Vector2 = feet_world + StickmanLocomotion.FOOT_OFFSET
var stand_torso: Vector2 = (StickmanRagdoll.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,
_check(absf(hip_world.y - ground_y) <= StickmanRagdoll.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)"
@@ -820,12 +824,12 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
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,
_check(ragdoll._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,
% str(ragdoll._captured_landing_center))
_check(absf(ragdoll._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])
% [ragdoll._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)])