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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user