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
+63 -55
View File
@@ -42,11 +42,12 @@ func _spawn_rig(stage: Node2D, position: Vector2) -> StickmanRig:
func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
_check(rig._collision_bodies.size() == 10, "rig builds ten collision proxies")
_check(rig._collision_lengths.size() == 9, "rig records nine fitted capsule lengths")
for part_name: String in rig.COLLISION_PARTS:
var definition: Dictionary = rig.COLLISION_PARTS[part_name]
var body := rig._collision_bodies.get(part_name) as AnimatableBody2D
var locomotion: StickmanLocomotion = rig.get_locomotion()
_check(locomotion.collision_bodies.size() == 10, "locomotion builds ten collision proxies")
_check(locomotion.collision_lengths.size() == 9, "locomotion records nine fitted capsule lengths")
for part_name: String in locomotion.COLLISION_PARTS:
var definition: Dictionary = locomotion.COLLISION_PARTS[part_name]
var body := locomotion.collision_bodies.get(part_name) as AnimatableBody2D
_check(body != null, "%s proxy exists" % part_name)
if body == null:
continue
@@ -65,9 +66,9 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
var circle := shape_node.shape as CircleShape2D
_check(circle != null, "head uses a circle")
if circle != null:
_check(circle.radius >= rig.HEAD_COLLISION_RADIUS, "head radius covers at least the fallback radius")
_check(circle.radius >= locomotion.HEAD_COLLISION_RADIUS, "head radius covers at least the fallback radius")
var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
var bounds: Dictionary = rig._mounted_visual_bounds_local(head_visual)
var bounds: Dictionary = locomotion.mounted_visual_bounds_local(head_visual)
_check(bool(bounds.get("valid", false)), "mounted head has measurable geometry")
if bool(bounds.get("valid", false)):
_check(shape_node.position.is_equal_approx((bounds["rect"] as Rect2).get_center()), "head collider follows the mounted bbox center")
@@ -77,14 +78,14 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
_check(capsule != null, "%s uses a capsule" % part_name)
if capsule == null:
continue
var length := float(rig._collision_lengths.get(part_name, -1.0))
var length := float(locomotion.collision_lengths.get(part_name, -1.0))
_check(is_equal_approx(capsule.height, length), "%s capsule uses its fitted length" % part_name)
_check(is_equal_approx(capsule.radius, float(definition["radius"])), "%s capsule uses its configured radius" % part_name)
_check(shape_node.position.is_equal_approx(Vector2(0.0, length * 0.5)), "%s capsule is centered along +Y" % part_name)
_check(is_zero_approx(shape_node.rotation), "%s capsule needs no hanging-frame rotation" % part_name)
var empty_visual := Node2D.new()
var empty_bounds: Dictionary = rig._mounted_visual_bounds_local(empty_visual)
var empty_bounds: Dictionary = locomotion.mounted_visual_bounds_local(empty_visual)
_check(not bool(empty_bounds.get("valid", true)), "empty visual bounds use the fallback path")
empty_visual.free()
@@ -100,18 +101,19 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
func _test_collision_proxy_physics_alignment(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
rig.walk_to(rig.global_position + Vector2(-400.0, 0.0), 300.0)
for _frame in 8:
await physics_frame
await process_frame
rig._sync_collision_bodies()
locomotion.sync_collision_bodies()
await physics_frame
await process_frame
_check(rig.facing_profile == StickmanRig.FacingProfile.LEFT, "left walk uses the mirrored collision-proxy path")
for part_name: String in rig.COLLISION_PARTS:
var definition: Dictionary = rig.COLLISION_PARTS[part_name]
for part_name: String in locomotion.COLLISION_PARTS:
var definition: Dictionary = locomotion.COLLISION_PARTS[part_name]
var visual := rig.get_node_or_null(NodePath("Body/" + String(definition["visual"]))) as Node2D
var body := rig._collision_bodies.get(part_name) as AnimatableBody2D
var body := locomotion.collision_bodies.get(part_name) as AnimatableBody2D
if visual == null or body == null:
_check(false, "%s has nodes for PhysicsServer alignment" % part_name)
continue
@@ -119,62 +121,63 @@ func _test_collision_proxy_physics_alignment(rig: StickmanRig) -> void:
_check(server_transform.origin.distance_to(visual.global_position) < 0.01, "%s PhysicsServer origin follows its walking visual" % part_name)
_check(server_transform.x.distance_to(visual.global_transform.x) < 0.001, "%s PhysicsServer X basis follows its walking visual" % part_name)
_check(server_transform.y.distance_to(visual.global_transform.y) < 0.001, "%s PhysicsServer Y basis follows its walking visual" % part_name)
rig._cancel_walking()
rig.get_locomotion().cancel_walk()
rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
func _test_reactions(rig: StickmanRig) -> void:
rig._impact_reactions.clear()
var locomotion: StickmanLocomotion = rig.get_locomotion()
locomotion.impact_reactions.clear()
rig.apply_collision_reaction("unknown", Vector2.RIGHT, 400.0)
rig.apply_collision_reaction("head", Vector2.ZERO, 400.0)
rig.apply_collision_reaction("head", Vector2.RIGHT, 0.0)
_check(rig._impact_reactions.is_empty(), "invalid reactions are ignored")
_check(locomotion.impact_reactions.is_empty(), "invalid reactions are ignored")
var head_proxy := rig._collision_bodies["head"] as AnimatableBody2D
var head_proxy := locomotion.collision_bodies["head"] as AnimatableBody2D
var head_shape := head_proxy.get_node("CollisionShape2D") as CollisionShape2D
var head_circle := head_shape.shape as CircleShape2D
var upper_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, -head_circle.radius * 0.7))
rig.apply_collision_reaction("head", Vector2(0.5, 0.5).normalized(), 400.0, upper_left)
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "upper-left head contact lowers the struck side")
rig._impact_reactions.clear()
_check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "upper-left head contact lowers the struck side")
locomotion.impact_reactions.clear()
var upper_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, -head_circle.radius * 0.7))
rig.apply_collision_reaction("head", Vector2(-0.5, 0.5).normalized(), 400.0, upper_right)
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "upper-right head contact lowers the struck side")
rig._impact_reactions.clear()
_check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "upper-right head contact lowers the struck side")
locomotion.impact_reactions.clear()
var lower_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, head_circle.radius * 0.7))
rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_left)
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "lower-left upward contact inverts the upper-left response")
rig._impact_reactions.clear()
_check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "lower-left upward contact inverts the upper-left response")
locomotion.impact_reactions.clear()
var lower_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, head_circle.radius * 0.7))
rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_right)
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "lower-right upward contact inverts the upper-right response")
rig._impact_reactions.clear()
_check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "lower-right upward contact inverts the upper-right response")
locomotion.impact_reactions.clear()
rig.apply_collision_reaction("left_lower_arm", Vector2.RIGHT, 10000.0)
var lower: Dictionary = rig._impact_reactions.get("LeftLowerArm", {})
var upper: Dictionary = rig._impact_reactions.get("LeftUpperArm", {})
var lower: Dictionary = locomotion.impact_reactions.get("LeftLowerArm", {})
var upper: Dictionary = locomotion.impact_reactions.get("LeftUpperArm", {})
_check(is_equal_approx(absf(float(lower.get("angle", 0.0))), 0.6), "primary reaction caps at 0.6 radians")
_check(is_equal_approx(absf(float(upper.get("angle", 0.0))), 0.24), "secondary reaction applies 0.4 weight")
var strong_angle := float(lower.get("angle", 0.0))
(rig._impact_reactions["LeftLowerArm"] as Dictionary)["strength"] = 0.25
(locomotion.impact_reactions["LeftLowerArm"] as Dictionary)["strength"] = 0.25
rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 40.0)
lower = rig._impact_reactions["LeftLowerArm"]
lower = locomotion.impact_reactions["LeftLowerArm"]
_check(is_equal_approx(float(lower["angle"]), strong_angle), "weaker opposite hit retains the stronger signed angle")
_check(is_equal_approx(float(lower["strength"]), 1.0), "weaker hit refreshes reaction strength")
rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 10000.0)
lower = rig._impact_reactions["LeftLowerArm"]
lower = locomotion.impact_reactions["LeftLowerArm"]
_check(signf(float(lower["angle"])) != signf(strong_angle), "equal capped hit may replace the reaction direction")
var lower_bone := rig._reaction_bones["LeftLowerArm"] as Bone2D
var lower_bone := locomotion.reaction_bones["LeftLowerArm"] as Bone2D
var base_rotation := lower_bone.rotation
var max_offset := 0.0
for frame in 30:
rig._apply_impact_reactions(1.0 / 60.0)
locomotion.apply_impact_reactions(1.0 / 60.0)
max_offset = maxf(max_offset, absf(angle_difference(base_rotation, lower_bone.rotation)))
_check(max_offset <= rig.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap")
rig._apply_impact_reactions(1.0)
_check(rig._impact_reactions.is_empty(), "expired reactions are erased")
_check(max_offset <= locomotion.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap")
locomotion.apply_impact_reactions(1.0)
_check(locomotion.impact_reactions.is_empty(), "expired reactions are erased")
_check(is_zero_approx(angle_difference(base_rotation, lower_bone.rotation)), "expired reaction restores its captured base rotation")
@@ -196,11 +199,11 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi
_check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "PLAY to DIRECT restores the authored stickman position")
_check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "PLAY to DIRECT restores the authored stickman rotation")
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile")
for marker_name: String in rig.STAND_POSE:
for marker_name: String in StickmanRagdoll.STAND_POSE:
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
var expected: Dictionary = rig.STAND_POSE[marker_name]
var expected: Dictionary = StickmanRagdoll.STAND_POSE[marker_name]
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to standing after PLAY" % marker_name)
_check(absf(angle_difference(torso_marker.rotation, float(rig.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame")
_check(absf(angle_difference(torso_marker.rotation, float(StickmanRagdoll.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame")
func _test_ghost_collision(stage: Node2D) -> void:
@@ -213,6 +216,7 @@ func _test_ghost_collision(stage: Node2D) -> void:
func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
stage.set_mode(stage.StageMode.PLAY)
var prop := PropBlock.new()
prop.position = Vector2(50.0, 25.0)
prop.linear_velocity = Vector2(400.0, 0.0)
@@ -239,8 +243,9 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
_check((emitted_collisions[0]["position"] as Vector2).is_equal_approx(prop.global_position), "PropBlock fallback position is world-space")
_check(emitted_separations == [fallback_body], "PropBlock mirrors body_exited with separated")
var lower := rig._collision_bodies["left_lower_arm"] as AnimatableBody2D
var upper := rig._collision_bodies["left_upper_arm"] as AnimatableBody2D
var locomotion: StickmanLocomotion = rig.get_locomotion()
var lower := locomotion.collision_bodies["left_lower_arm"] as AnimatableBody2D
var upper := locomotion.collision_bodies["left_upper_arm"] as AnimatableBody2D
var pair_key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var rule := {
"id": 4500,
@@ -254,22 +259,23 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
}
stage._event_rules.clear()
stage._event_rules.append(rule)
var initial_queue_size := rig.queue_size()
var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
var initial_runtime_size: int = runner._run_queue.size()
stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(10.0, 20.0), prop)
_check(stage._collision_pairs.has(pair_key), "first part contact creates a rig/prop pair")
_check(rig.queue_size() == initial_queue_size + 1, "first pair contact dispatches a rig-to-prop rule")
_check(runner._run_queue.size() == initial_runtime_size + 1, "first pair contact dispatches a rig-to-prop rule")
stage._on_prop_collided(upper, Vector2.RIGHT, Vector2(11.0, 20.0), prop)
var pair: Dictionary = stage._collision_pairs[pair_key]
_check((pair.get("bodies", {}) as Dictionary).size() == 2, "second part contact joins the active body set")
_check(rig.queue_size() == initial_queue_size + 1, "second active part does not repeat the pair rule")
_check(rig._impact_reactions.has("LeftLowerArm") and rig._impact_reactions.has("LeftUpperArm"), "each newly contacted part can react")
_check(runner._run_queue.size() == initial_runtime_size + 1, "second active part does not repeat the pair rule")
_check(locomotion.impact_reactions.has("LeftLowerArm") and locomotion.impact_reactions.has("LeftUpperArm"), "each newly contacted part can react")
stage._on_prop_separated(lower, prop)
_check(stage._collision_pairs.has(pair_key), "first part exit retains the pair")
stage._on_prop_separated(upper, prop)
_check(not stage._collision_pairs.has(pair_key), "last part exit clears the pair")
stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(12.0, 20.0), prop)
_check(rig.queue_size() == initial_queue_size + 2, "full separation permits a new collision rule")
_check(runner._run_queue.size() == initial_runtime_size + 2, "full separation permits a new collision rule")
var prop_ids: Array[int] = [prop.get_instance_id()]
stage._purge_collision_pairs_for_ids(prop_ids)
_check(not stage._collision_pairs.has(pair_key), "prop deletion purge removes active pairs")
@@ -291,16 +297,16 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
}
stage._event_rules.clear()
stage._event_rules.append(generic_rule)
var before_generic := rig.queue_size()
var before_generic: int = runner._run_queue.size()
stage._on_prop_collided(fallback_body, Vector2.RIGHT, Vector2(13.0, 20.0), prop)
_check(rig.queue_size() == before_generic + 1, "non-stickman contacts preserve prop-to-other event ordering")
_check(runner._run_queue.size() == before_generic + 1, "non-stickman contacts preserve prop-to-other event ordering")
var invalid_proxy := Node2D.new()
invalid_proxy.set_meta("stickman_rig", rig)
stage._world.add_child(invalid_proxy)
var before_invalid := rig.queue_size()
var before_invalid: int = runner._run_queue.size()
stage._on_prop_collided(invalid_proxy, Vector2.RIGHT, Vector2.ZERO, prop)
_check(rig.queue_size() == before_invalid, "incomplete stickman metadata dispatches no generic rule")
_check(runner._run_queue.size() == before_invalid, "incomplete stickman metadata dispatches no generic rule")
var ragdoll_proxy := Node2D.new()
ragdoll_proxy.set_meta("stickman_ragdoll_body", true)
@@ -319,18 +325,19 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
func _test_ragdoll_transitions(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
_check(not rig._impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll")
_check(not locomotion.impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll")
rig.set_ragdoll(true)
_check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll")
_check(_all_proxy_layers_equal(rig, 0), "ragdoll disables animated collision proxies")
_check(rig._impact_reactions.is_empty(), "ragdoll entry clears reactions")
_check(locomotion.impact_reactions.is_empty(), "ragdoll entry clears reactions")
rig.request_recovery()
_check(rig.state == StickmanRig.RigState.RECOVERING, "recovery request starts the stand-up state")
_check(_all_proxy_layers_equal(rig, 1), "collision proxies stay enabled throughout recovery")
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
_check(rig._impact_reactions.is_empty(), "recovering rig ignores visual reactions")
_check(locomotion.impact_reactions.is_empty(), "recovering rig ignores visual reactions")
rig.set_ragdoll(true)
_check(rig.state == StickmanRig.RigState.RAGDOLL, "recovery can be interrupted by ragdoll")
@@ -341,7 +348,7 @@ func _test_ragdoll_transitions(rig: StickmanRig) -> void:
func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
for body: AnimatableBody2D in rig._collision_bodies.values():
for body: AnimatableBody2D in rig.get_locomotion().collision_bodies.values():
if body.collision_layer != expected or body.collision_mask != expected:
return false
return true
@@ -349,8 +356,9 @@ func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
func _proxy_shape_sizes(rig: StickmanRig) -> Dictionary:
var sizes := {}
for part_name: String in rig._collision_bodies:
var body := rig._collision_bodies[part_name] as AnimatableBody2D
var locomotion: StickmanLocomotion = rig.get_locomotion()
for part_name: String in locomotion.collision_bodies:
var body := locomotion.collision_bodies[part_name] as AnimatableBody2D
var shape_node := body.get_node(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node.shape is CircleShape2D:
sizes[part_name] = (shape_node.shape as CircleShape2D).radius