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
+24 -15
View File
@@ -73,8 +73,10 @@ func _add_rule(stage: Node2D, source: Node2D, area: TriggerArea, target: Stickma
func _clear_actions(rig: StickmanRig) -> void:
rig.action_queue.clear()
rig.stop_queue()
rig.clear_queue()
var runner := rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
runner.hide_speech()
func _test_spawner_and_rule_contract() -> void:
@@ -102,31 +104,35 @@ func _test_source_specific_edges() -> void:
var world_area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
_check(not world_area_rect.has_point(stage._movable_point(source)), "regression setup keeps feet outside the leg-level area")
stage._update_area_entry()
_check(target.action_queue.size() == 1, "source crossing queues exactly one consequence")
var action: Dictionary = target.action_queue[0]
_check(action.get("type", "") == "speak" and action.get("text", "") == "source only" and bool(action.get("reactive", false)), "speak consequence is normalized as a reactive action")
var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner
_check(not runner._overlay_speech.is_empty(), "source crossing starts one overlay consequence")
_check(String(runner._overlay_speech.get("text", "")) == "source only", "speak consequence is normalized into overlay speech")
# SceneTree.physics_frame is emitted before node _physics_process callbacks;
# wait through the following tick so the runner can begin the action.
await physics_frame
await physics_frame
_check(target._speech_active and target._speech_bubble != null and target._speech_bubble.visible, "queued consequence executes as a visible speech bubble")
_check(runner._speech_bubble != null and runner._speech_bubble.visible, "queued consequence executes as a visible speech bubble")
runner._overlay_speech["time_left"] = 0.5
stage._update_area_entry()
_check(target.action_queue.size() == 1, "polling while inside does not repeat")
_check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 0.5), "polling while inside does not repeat")
_move_rig_body_center(source, Vector2(-1500.0, 0.0))
stage._update_area_entry()
# The swept-bounds detector deliberately retains the previous pose for one
# poll, so a second far-away sample commits the exit edge.
stage._update_area_entry()
_move_rig_body_center(source, area.global_position)
stage._update_area_entry()
_check(target.action_queue.size() == 2, "exit then re-entry queues one additional consequence")
_check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 1.0), "exit then re-entry restarts the consequence")
_clear_actions(target)
_move_rig_body_center(other, area.global_position)
stage._update_area_entry()
_check(target.action_queue.is_empty(), "different stickman cannot trigger source-specific rule")
_check(runner._overlay_speech.is_empty(), "different stickman cannot trigger source-specific rule")
var prop := stage._spawner.spawn("prop", Vector2(-1500.0, 0.0)) as PropBlock
stage._finalize_placed_node(prop)
prop.freeze = false
prop.global_position = Vector2.ZERO
stage._update_area_entry()
_check(target.action_queue.is_empty(), "unfrozen prop cannot trigger source-specific rule")
_check(runner._overlay_speech.is_empty(), "unfrozen prop cannot trigger source-specific rule")
await _free_stage(stage)
@@ -141,16 +147,18 @@ func _test_wildcard_and_pair_independence() -> void:
stage._update_area_entry()
_move_rig_body_center(source_a, area_a.global_position)
stage._update_area_entry()
var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner
runner._overlay_speech["time_left"] = 0.25
_move_rig_body_center(source_a, Vector2(-1500.0, 0.0))
stage._update_area_entry()
_move_rig_body_center(source_b, area_a.global_position)
stage._update_area_entry()
_check(target.action_queue.size() == 2, "wildcard source fires independently for multiple movables")
_check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 1.0), "wildcard source fires independently for multiple movables")
_clear_actions(target)
_add_rule(stage, source_b, area_b, target, "second pair")
_move_rig_body_center(source_b, area_b.global_position)
stage._update_area_entry()
_check(target.action_queue.size() == 1, "second area/source pair has independent edge state")
_check(String(runner._overlay_speech.get("text", "")) == "second pair", "second area/source pair has independent edge state")
await _free_stage(stage)
@@ -163,15 +171,16 @@ func _test_transforms_points_and_cleanup() -> void:
var source := _spawn_rig(stage, Vector2(-300.0, 0.0))
var target := _spawn_rig(stage, Vector2(2000.0, 0.0))
_add_rule(stage, source, area, target, "transformed")
_check(stage._movable_point(source) == source.global_position - STICKMAN_RIG.FOOT_OFFSET, "stickman overlap point is feet, not root")
_check(stage._movable_point(source) == source.global_position - StickmanLocomotion.FOOT_OFFSET, "stickman overlap point is feet, not root")
_move_rig_body_center(source, area.global_position)
stage._update_area_entry()
_check(target.action_queue.size() == 1, "current transformed area overlaps visible stickman geometry")
var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner
_check(String(runner._overlay_speech.get("text", "")) == "transformed", "current transformed area overlaps visible stickman geometry")
_clear_actions(target)
var no_rule_area := _spawn_area(stage, Vector2(-500.0, 0.0))
source.global_position = no_rule_area.global_position + STICKMAN_RIG.FOOT_OFFSET
source.global_position = no_rule_area.global_position + StickmanLocomotion.FOOT_OFFSET
stage._update_area_entry()
_check(target.action_queue.is_empty(), "area with no matching rule is a no-op")
_check(runner._overlay_speech.is_empty(), "area with no matching rule is a no-op")
var stale_rule := _add_rule(stage, source, area, target, "stale")
var stale_id := target.get_instance_id()
target.free()