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
+34 -28
View File
@@ -161,23 +161,24 @@ func _test_reactive_runtime_lane() -> void:
finished.append({ "action": action, "index": index })
)
rig.start_queue()
rig._update_runner(0.0)
_check(rig._action_phase == rig.ActionPhase.WAITING, "authored primary wait starts")
var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest())
_check(runner._action_phase == runner.ActionPhase.WAITING, "authored primary wait starts")
rig.enqueue_reactive([{ "type": "speak", "text": "One", "duration": 5.0 }])
_check(rig._action_phase == rig.ActionPhase.WAITING, "overlay speak leaves primary wait running")
_check(String(rig._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot")
_check(runner._action_phase == runner.ActionPhase.WAITING, "overlay speak leaves primary wait running")
_check(String(runner._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot")
rig.enqueue_reactive([{ "type": "speak", "text": "Two", "duration": 5.0 }])
_check(String(rig._overlay_speech.get("text", "")) == "Two", "new overlay speak replaces the previous overlay")
_check(String(runner._overlay_speech.get("text", "")) == "Two", "new overlay speak replaces the previous overlay")
_check(finished.is_empty(), "overlay replacement emits no primary completion")
rig.enqueue_reactive([{ "type": "wait", "duration": 1.0 }])
_check(rig._action_phase == rig.ActionPhase.NONE, "sequential reactive action cancels the active primary")
_check(runner._action_phase == runner.ActionPhase.NONE, "sequential reactive action cancels the active primary")
_check(finished.is_empty(), "sequential interruption emits no false action_finished")
_check(rig._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future")
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait"
and bool(rig._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane")
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1,
_check(runner._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future")
_check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait"
and bool(runner._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane")
_check(int(runner._run_queue[1].get("queue_index", -1)) == 1,
"canceled authored action is abandoned and the next authored item is retained")
# A takeover must discard the canceled/current item and any pending reactive
@@ -188,17 +189,17 @@ func _test_reactive_runtime_lane() -> void:
{ "type": "wait", "duration": 20.0 },
]
rig.start_queue()
rig._update_runner(0.0)
runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest())
rig.enqueue_reactive([
{ "type": "wait", "duration": 1.0 },
{ "type": "ragdoll" },
])
_check(rig._run_queue.size() == 2, "takeover retains itself plus authored future only")
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll",
_check(runner._run_queue.size() == 2, "takeover retains itself plus authored future only")
_check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll",
"takeover supersedes earlier same-batch primary work")
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1,
_check(int(runner._run_queue[1].get("queue_index", -1)) == 1,
"takeover does not replay the completed or canceled authored item")
_check(rig._overlay_speech.is_empty(), "takeover cancels the active overlay")
_check(runner._overlay_speech.is_empty(), "takeover cancels the active overlay")
_check(rig.action_queue.size() == 2, "reactive work never mutates the authored queue")
rig.free()
@@ -231,7 +232,8 @@ func _test_spawn_pose_survives_first_solve() -> void:
"spawn solve preserves ghost joint position: %s (delta %.3f)" % [path, position_delta])
_check(rotation_delta <= 0.02,
"spawn solve preserves ghost bone rotation: %s (delta %.4f)" % [path, rotation_delta])
_check(rig._spawn_pose.size() == rig.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers")
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
_check(ragdoll._spawn_pose.size() == StickmanRagdoll.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers")
rig.free()
@@ -240,7 +242,8 @@ func _test_left_walk_finish_pose() -> void:
root.add_child(rig)
await process_frame
rig.set_facing_profile(rig.FacingProfile.LEFT)
rig._walking = true
var locomotion: StickmanLocomotion = rig.get_locomotion()
locomotion._walking = true
rig._set_limb_mods_enabled(true)
var stopped_frame: Dictionary = {}
for joint: String in rig._limb_bones:
@@ -254,7 +257,7 @@ func _test_left_walk_finish_pose() -> void:
var hand_marker := rig.get_node("IK_Targets/Left_Hand") as Marker2D
var stopped_marker_position := hand_marker.position + Vector2(17.0, -23.0)
hand_marker.position = stopped_marker_position
rig._finish_walk("regression")
locomotion._finish_walk()
_check(hand_marker.position.is_equal_approx(stopped_marker_position),
"walk finish preserves the current animation marker frame")
for joint: String in stopped_frame:
@@ -270,9 +273,9 @@ func _test_left_walk_finish_pose() -> void:
var live_upper := left_arm["upper"] as Bone2D
var live_rotation: float = live_upper.rotation + 0.33
live_upper.rotation = live_rotation
rig._walking = true
locomotion._walking = true
rig._set_limb_mods_enabled(true)
rig._cancel_walking(false)
locomotion.cancel_walk(false)
_check(absf(angle_difference(live_upper.rotation, live_rotation)) < 0.001,
"ragdoll-style walk cancellation preserves the current solved limb pose")
_check((rig._bend_modifications["LeftArm"] as SkeletonModification2DTwoBoneIK).enabled,
@@ -286,21 +289,22 @@ func _test_stop_clears_head_reaction() -> void:
await process_frame
var rig := stage._spawner.spawn("stickman", Vector2.ZERO) as StickmanRig
stage._finalize_placed_node(rig)
var head := rig._reaction_bones.get("Head") as Bone2D
var locomotion: StickmanLocomotion = rig.get_locomotion()
var head := locomotion.reaction_bones.get("Head") as Bone2D
var base_rotation: float = head.rotation
rig.apply_collision_reaction("head", Vector2.DOWN, 500.0)
rig._apply_impact_reactions(0.01)
_check(not rig._impact_reactions.is_empty(), "head collision starts a flinch reaction")
locomotion.apply_impact_reactions(0.01)
_check(not locomotion.impact_reactions.is_empty(), "head collision starts a flinch reaction")
stage.set_mode(stage.StageMode.PLAY)
stage.set_mode(stage.StageMode.EDIT)
_check(rig._impact_reactions.is_empty(), "STOP clears the active head reaction")
_check(locomotion.impact_reactions.is_empty(), "STOP clears the active head reaction")
_check(absf(angle_difference(head.rotation, base_rotation)) < 0.001, "STOP restores the head without a recovery tween")
var stale_prop := PropBlock.new()
var stale_proxy := AnimatableBody2D.new()
stale_proxy.set_meta("stickman_rig", rig)
stale_proxy.set_meta("part_name", "head")
stage._on_prop_collided(stale_proxy, Vector2.DOWN, Vector2.ZERO, stale_prop)
_check(rig._impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch")
_check(locomotion.impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch")
stale_prop.free()
stale_proxy.free()
stage.free()
@@ -310,16 +314,18 @@ func _test_stop_clears_head_reaction() -> void:
func _test_source_contracts() -> void:
var stage_source: String = FileAccess.get_file_as_string("res://scripts/sandbox_stage.gd")
var rig_source: String = FileAccess.get_file_as_string("res://scripts/stickman_rig.gd")
var ragdoll_source: String = FileAccess.get_file_as_string("res://scripts/stickman_ragdoll.gd")
_check(stage_source.contains("TERRAIN_DRAG_THRESHOLD_PX"), "terrain uses a drag threshold")
_check(stage_source.contains("PhysicsServer2D.body_set_state"), "frozen prop commits synchronize physics")
_check(stage_source.contains("transform_started.connect"), "gizmo start cancels deferred restoration")
_check(stage_source.contains("if current_mode != StageMode.PLAY:"), "collision callbacks are gated to PLAY mode")
_check(stage_source.contains("_speak_stop_current_check") and stage_source.contains("\"stop_current\": _speak_stop_current_check.button_pressed"), "legacy rule speak persists stop-current")
_check(rig_source.contains("_primary_speech") and rig_source.contains("_overlay_speech"), "rig separates primary and overlay speech")
var runner_source: String = FileAccess.get_file_as_string("res://scripts/stickman_action_runner.gd")
_check(runner_source.contains("_primary_speech") and runner_source.contains("_overlay_speech"), "runner separates primary and overlay speech")
_check(rig_source.contains("_sync_speech_bubble_mirror"), "rig counter-mirrors LEFT speech")
_check(rig_source.contains("initialize_spawn_pose_from_skeleton"), "rig captures factory spawn pose")
_check(rig_source.contains("bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))"), "spawn endpoints include Bone2D bone_angle")
_check(rig_source.contains("_stand_up_generation") and rig_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded")
_check(ragdoll_source.contains("bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))"), "spawn endpoints include Bone2D bone_angle")
_check(ragdoll_source.contains("_stand_up_generation") and ragdoll_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded")
func _check(condition: bool, label: String) -> void: