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
+3 -3
View File
@@ -1,6 +1,6 @@
# test_phase3b_library.gd
# Headless tests for Phase 3b Asset Library (no pixel rendering):
# 1. StickmanLibrary.scan() returns 3 entries (test/basic/break) sorted by
# 1. StickmanLibrary.scan() returns the 4 checked-in entries sorted by
# display name; empty stickman_name falls back to the filename basename.
# 2. Corrupt / body-parts-less .stk files are skipped (scan dir override).
# 3. make_entry() valid path -> name+data; missing/corrupt path -> {}.
@@ -70,11 +70,11 @@ func _test_scan() -> void:
print("--- StickmanLibrary.scan() ---")
var lib = STICKMAN_LIBRARY.new()
var entries: Array[Dictionary] = lib.scan()
_check(entries.size() == 3, "scan returns 3 entries (got %d)" % entries.size())
_check(entries.size() == 4, "scan returns 4 entries (got %d)" % entries.size())
var names: Array[String] = []
for e: Dictionary in entries:
names.append(String(e["name"]))
_check(names == ["Basic", "break", "test"],
_check(names == ["Basic", "Basic Broken", "break", "test"],
"entries sorted by display name (got %s)" % str(names))
_check(String(_by_path(entries, "basic.stk")["name"]) == "Basic",
"basic.stk display name is 'Basic' (stickman_name)")
+2 -2
View File
@@ -279,8 +279,8 @@ func _test_bug4_stage_grid_no_prehighlight() -> void:
% stage._spawner.get_selected_stickman_path())
# Real scan has 3 stickmen -> no single-item auto-select skip.
var grid: GridContainer = stage._selector._grid
_check(grid.get_child_count() == 3,
"real scan populates 3 stickman cells (got %d)" % grid.get_child_count())
_check(grid.get_child_count() == 4,
"real scan populates 4 stickman cells (got %d)" % grid.get_child_count())
_check(not stage._selector.has_method("_is_selected"),
"stage selector exposes no pre-highlight API")
_check(not _any_cell_has_stylebox_override(stage._selector),
+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)])
+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
+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:
+10 -9
View File
@@ -366,7 +366,7 @@ func _test_bug3_walk_termination() -> void:
print("--- Bug 3: nav-mode termination + off-mesh direct still works ---")
# On-mesh waypoint near the slab's right edge (the former oscillation zone):
# the rig must terminate exactly once through the nav branch and stay put.
await _walk_case("on-mesh (near slab edge)", Vector2(88.0, -8.0), "nav")
await _walk_case("on-mesh (near slab edge)", Vector2(88.0, 0.0), "nav")
# Off-mesh waypoint in open space: direct mode must still reach arrival.
await _walk_case("off-mesh (open space)", Vector2(250.0, 0.0), "direct")
@@ -388,24 +388,25 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v
_arrived_count = 0
rig.arrived.connect(_on_arrived)
rig.walk_to(target_feet, 200.0)
_check(not rig._walk_done, "%s: walk not done immediately" % label)
var locomotion: StickmanLocomotion = rig.get_locomotion()
_check(locomotion != null and not locomotion._walk_done, "%s: walk not done immediately" % label)
var frames := 0
var mode_flipped := false
while not rig._walk_done and frames < MAX_WALK_FRAMES:
if rig._walk_mode_latched and rig._walk_mode != expected_mode:
while not locomotion._walk_done and frames < MAX_WALK_FRAMES:
if locomotion._walk_mode_latched and locomotion._walk_mode != expected_mode:
mode_flipped = true
await physics_frame
frames += 1
_check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames])
_check(locomotion._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames])
_check(_arrived_count == 1, "%s: exactly one arrived emission (got %d)" % [label, _arrived_count])
_check(rig._walk_mode_latched, "%s: steering mode latched" % label)
_check(rig._walk_mode == expected_mode,
"%s: latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode])
_check(locomotion._walk_mode_latched, "%s: steering mode latched" % label)
_check(locomotion._walk_mode == expected_mode,
"%s: latched to '%s' (got '%s')" % [label, expected_mode, locomotion._walk_mode])
_check(not mode_flipped, "%s: latched mode never flipped mid-walk" % label)
# Position snapped to the exact final root target (feet + FOOT_OFFSET).
var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET
var final_root: Vector2 = target_feet + StickmanLocomotion.FOOT_OFFSET
_check(rig.global_position.distance_to(final_root) <= 0.01,
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
+32 -23
View File
@@ -21,7 +21,7 @@
# * After Back the rule-builder state is IDLE/empty AND _action_popup is
# visible again (PopupMenu.visible IS observable headless).
#
# Bug 3 (head mirror jitter): master_rig.tscn's Head LookAt modification is a
# Bug 3 (head mirror jitter): scenes/stickman_rig.tscn's Head LookAt modification is a
# full-range (-180..180), non-inverted, non-local-space constraint.
# * Constraint flags/band are asserted directly on the instantiated rig.
# * The Head marker is settled at the walk-left pose (-100,-614), then
@@ -261,7 +261,7 @@ func _test_bug3_head_lookat() -> void:
"head bone / Body/Head visual / Head aim marker all resolve")
# 2) Standing-still check at STAND_POSE: no frame-to-frame motion at all.
var stand_pos: Vector2 = (RIG.STAND_POSE["Head"] as Dictionary)["pos"]
var stand_pos: Vector2 = (StickmanRagdoll.STAND_POSE["Head"] as Dictionary)["pos"]
head_target.position = stand_pos
for i: int in WARMUP_FRAMES:
await physics_frame
@@ -316,7 +316,7 @@ func _measure_head(head_bone: Bone2D, head_body: Node2D, frames: int) -> Diction
func _test_bug4_reactive_actions() -> void:
print("")
print("--- Bug 4: enqueue_reactive tags + clear_reactive_actions restores ---")
print("--- Bug 4: reactive runtime lane never mutates authored actions ---")
var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the queue test")
@@ -328,6 +328,7 @@ func _test_bug4_reactive_actions() -> void:
rig.queue_action({ "type": "speak", "text": "authored-a", "duration": 2.0 })
rig.queue_action({ "type": "wait", "duration": 1.0 })
_check(rig.queue_size() == 2, "authored queue holds 2 actions")
var runner := rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
# Cycle 1: a reactive action is injected (rule firing during PLAY), which
# auto-starts the runner; leaving PLAY stops the queue first, then
@@ -336,13 +337,13 @@ func _test_bug4_reactive_actions() -> void:
rig.queue_changed.connect(func(): qc1.append(true))
var r1: Array[Dictionary] = [{ "type": "speak", "text": "reactive-1", "duration": 1.0 }]
rig.enqueue_reactive(r1)
_check(rig.queue_size() == 3,
"reactive injection grows the queue to 3 (got %d)" % rig.queue_size())
_check(_count_reactive(rig) == 1,
"injected action is tagged reactive=true")
_check(rig.is_queue_running(),
"enqueue_reactive auto-resumes the runner from IDLE")
_check(qc1.size() == 1, "enqueue_reactive emits queue_changed")
_check(rig.queue_size() == 2,
"reactive injection leaves the authored queue at 2 (got %d)" % rig.queue_size())
_check(runner._run_queue.size() == 2 and _runtime_reactive_count(runner) == 0 and not runner._overlay_speech.is_empty(),
"overlay speak leaves only authored items in the primary runtime lane")
_check(not rig.is_queue_running(),
"overlay speak does not start the primary queue")
_check(qc1.is_empty(), "runtime-only injection does not emit authored queue_changed")
rig.stop_queue()
rig.clear_reactive_actions()
@@ -351,7 +352,7 @@ func _test_bug4_reactive_actions() -> void:
_check(_action_types(rig) == ["speak", "wait"],
"queue types after clear are exactly the authored actions (got %s)"
% str(_action_types(rig)))
_check(qc1.size() == 2, "clear_reactive_actions emits queue_changed on change")
_check(qc1.is_empty(), "runtime cleanup does not emit authored queue_changed")
# Cycle 2: a bigger reactive burst then stop + clear again - counts must
# stay stable (no 1,2,3 accumulation across repeated Play sessions).
@@ -360,10 +361,10 @@ func _test_bug4_reactive_actions() -> void:
{ "type": "ragdoll" },
]
rig.enqueue_reactive(r2)
_check(rig.queue_size() == 4,
"second injection grows to 4 (got %d)" % rig.queue_size())
_check(_count_reactive(rig) == 2,
"second burst tags both actions reactive")
_check(rig.queue_size() == 2 and runner._run_queue.size() == 3,
"takeover keeps 2 authored future items behind one reactive action")
_check(bool(runner._run_queue[0].get("reactive", false)),
"takeover action leads the runtime lane as reactive")
rig.stop_queue()
rig.clear_reactive_actions()
_check(rig.queue_size() == 2,
@@ -372,25 +373,25 @@ func _test_bug4_reactive_actions() -> void:
"queue types stay stable across two reactive cycles (got %s)"
% str(_action_types(rig)))
# clear_reactive_actions() must be a no-op while the runner is executing.
# Runtime cleanup may run while executing, but it never mutates authored work.
rig.start_queue()
_check(rig.is_queue_running(), "runner is EXECUTING after start_queue")
var r3: Array[Dictionary] = [{ "type": "recover" }]
rig.enqueue_reactive(r3)
_check(rig.queue_size() == 3,
"reactive action can still append while the runner is executing")
_check(rig.queue_size() == 2 and bool(runner._run_queue[0].get("reactive", false)),
"reactive takeover enters runtime lane while authored queue stays unchanged")
var qc3: Array = []
rig.queue_changed.connect(func(): qc3.append(true))
rig.clear_reactive_actions()
_check(rig.queue_size() == 3 and _count_reactive(rig) == 1,
"clear_reactive_actions is a no-op while EXECUTING (reactive action stays)")
_check(rig.queue_size() == 2 and _runtime_reactive_count(runner) == 0,
"clear_reactive_actions removes runtime reactive work while preserving authored actions")
_check(qc3.is_empty(),
"no queue_changed emitted by the EXECUTING no-op")
"runtime cleanup emits no authored queue_changed")
rig.stop_queue()
_check(not rig.is_queue_running(), "runner returns to IDLE after stop_queue")
rig.clear_reactive_actions()
_check(rig.queue_size() == 2 and _count_reactive(rig) == 0,
"after stop, clear drops the injected reactive action (queue 2)")
_check(rig.queue_size() == 2 and _runtime_reactive_count(runner) == 0,
"after stop, authored queue remains stable at 2")
await _free_stage(stage)
@@ -407,6 +408,14 @@ func _count_reactive(rig: StickmanRig) -> int:
return n
func _runtime_reactive_count(runner: StickmanActionRunner) -> int:
var count := 0
for item: Dictionary in runner._run_queue:
if bool(item.get("reactive", false)):
count += 1
return count
func _action_types(rig: StickmanRig) -> Array[String]:
var out: Array[String] = []
for a: Dictionary in rig.get_queue():
+22 -13
View File
@@ -15,6 +15,7 @@ extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
const LOCOMOTION := preload("res://scripts/stickman_locomotion.gd")
const SETTLE_FRAMES := 60
const MAX_FRAMES := 300
@@ -68,27 +69,33 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v
_arrived_count = 0
rig.arrived.connect(_on_arrived)
rig.walk_to(target_feet, 200.0)
_check(not rig._walk_mode_latched, "%s: mode not latched before map sync" % label)
var locomotion: StickmanLocomotion = rig.get_locomotion()
_check(locomotion != null, "%s: locomotion component is available" % label)
if locomotion == null:
stage.queue_free()
await process_frame
return
_check(not locomotion._walk_mode_latched, "%s: mode not latched before map sync" % label)
var frames := 0
while not rig._walk_done and frames < MAX_FRAMES:
while not locomotion._walk_done and frames < MAX_FRAMES:
await physics_frame
frames += 1
_check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames])
_check(locomotion._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames])
_check(_arrived_count == 1, "%s: exactly one arrived signal (got %d)" % [label, _arrived_count])
_check(rig._walk_mode_latched, "%s: mode is latched" % label)
_check(rig._walk_mode == expected_mode,
"%s: mode latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode])
_check(locomotion._walk_mode_latched, "%s: mode is latched" % label)
_check(locomotion._walk_mode == expected_mode,
"%s: mode latched to '%s' (got '%s')" % [label, expected_mode, locomotion._walk_mode])
# Final position equals the snapped root target (feet target + FOOT_OFFSET).
var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET
var final_root: Vector2 = target_feet + LOCOMOTION.FOOT_OFFSET
_check(rig.global_position.distance_to(final_root) <= 0.01,
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
# Body-bob settle: Torso marker restored to STAND_POSE (0,10).
# Completion freezes the solved final animation frame; it must not be reset
# or drift while the root is at rest.
var torso = rig.get_node_or_null(NodePath("IK_Targets/Torso"))
var frozen_torso_position: Vector2 = Vector2.ZERO
if torso != null:
var stand_torso: Dictionary = RIG.STAND_POSE["Torso"]
var expected_pos: Vector2 = stand_torso.get("pos", Vector2(0, 10))
_check((torso as Node2D).position.distance_to(expected_pos) <= 1.0,
"%s: Torso marker restored to standing pose (dist=%.3f)" % [label, (torso as Node2D).position.distance_to(expected_pos)])
frozen_torso_position = (torso as Node2D).position
_check(frozen_torso_position.is_finite(), "%s: Torso final animation marker is finite" % label)
else:
_check(false, "%s: IK_Targets/Torso missing" % label)
# Post-arrival stability for 60 physics frames.
@@ -101,6 +108,9 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v
break
_check(settled and rig.global_position == p0,
"%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES])
if torso != null:
_check((torso as Node2D).position.distance_to(frozen_torso_position) <= 0.01,
"%s: Torso final animation marker remains frozen" % label)
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
stage.queue_free()
@@ -114,4 +124,3 @@ func _check(condition: bool, message: String) -> void:
else:
_failures += 1
print("FAIL: " + message)
+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()
+51 -47
View File
@@ -3,8 +3,9 @@ extends SceneTree
## Focused deterministic regression checks for the Phase 6a terrain controller.
## Runtime terrain/ramp integration remains covered by the existing stage suites.
const RIG_SCENE := preload("res://master_rig.tscn")
const RIG_SCENE := preload("res://scenes/stickman_rig.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
const LOC := preload("res://scripts/stickman_locomotion.gd")
var _checks: int = 0
var _failures: int = 0
@@ -35,59 +36,62 @@ func _run() -> void:
func _test_air_pose_and_momentum(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
var left_hand := rig._get_ik_marker("Left_Hand")
var right_hand := rig._get_ik_marker("Right_Hand")
var left_base := left_hand.position
var right_base := right_hand.position
rig._enter_airborne(300.0)
_check(rig._terrain_motion == RIG.TerrainMotion.AIRBORNE, "enter_airborne selects AIRBORNE")
_check(is_equal_approx(rig._terrain_air_velocity.x, 300.0), "takeoff keeps signed horizontal velocity")
rig._terrain_air_velocity.y = RIG.AIR_POSE_FULL_SPEED
rig._air_time = 0.0
rig._apply_air_pose(1.0)
_check(rig._air_pose_weight > 0.99 and rig._air_pose_weight <= 1.0, "air pose weight clamps at full fall speed")
locomotion._enter_airborne(300.0)
_check(locomotion.terrain_motion == LOC.TerrainMotion.AIRBORNE, "enter_airborne selects AIRBORNE")
_check(is_equal_approx(locomotion.terrain_air_velocity.x, 300.0), "takeoff keeps signed horizontal velocity")
locomotion.terrain_air_velocity.y = LOC.AIR_POSE_FULL_SPEED
locomotion._air_time = 0.0
locomotion._apply_air_pose(1.0)
_check(locomotion.air_pose_weight > 0.99 and locomotion.air_pose_weight <= 1.0, "air pose weight clamps at full fall speed")
_check(left_hand.position != left_base and right_hand.position != right_base, "air pose offsets both hands")
rig._restore_air_pose()
locomotion._restore_air_pose()
_check(left_hand.position.is_equal_approx(left_base) and right_hand.position.is_equal_approx(right_base), "air pose restores exact captured markers")
rig._terrain_motion = RIG.TerrainMotion.GROUNDED
rig._enter_airborne(0.0)
var forward_left_base: Vector2 = rig._air_pose_base["Left_Hand"]
var forward_right_base: Vector2 = rig._air_pose_base["Right_Hand"]
rig._terrain_air_velocity.y = RIG.AIR_HAND_FORWARD_START_SPEED + 20.0
rig._air_time = 0.0
rig._apply_air_pose(1.0)
locomotion.terrain_motion = LOC.TerrainMotion.GROUNDED
locomotion._enter_airborne(0.0)
var forward_left_base: Vector2 = locomotion.air_pose_base["Left_Hand"]
var forward_right_base: Vector2 = locomotion.air_pose_base["Right_Hand"]
locomotion.terrain_air_velocity.y = LOC.AIR_HAND_FORWARD_START_SPEED + 20.0
locomotion._air_time = 0.0
locomotion._apply_air_pose(1.0)
_check(left_hand.position.x > forward_left_base.x and right_hand.position.x > forward_right_base.x, "hands reach forward before lift engages")
_check(is_equal_approx(left_hand.position.y, forward_left_base.y) and is_equal_approx(right_hand.position.y, forward_right_base.y), "early forward reach does not lift hands")
rig._terrain_air_velocity.y = RIG.AIR_POSE_FULL_SPEED
rig._apply_air_pose(1.0)
locomotion.terrain_air_velocity.y = LOC.AIR_POSE_FULL_SPEED
locomotion._apply_air_pose(1.0)
_check(left_hand.position.y < forward_left_base.y and right_hand.position.y < forward_right_base.y, "hands lift after forward reach")
rig._restore_air_pose()
rig._terrain_air_velocity = Vector2(300.0, 0.0)
rig._terrain_air_velocity.x *= exp(-RIG.AIR_DRAG_X * 0.1)
_check(rig._terrain_air_velocity.x > 0.0 and rig._terrain_air_velocity.x < 300.0, "air drag preserves forward momentum while decaying it")
locomotion._restore_air_pose()
locomotion.terrain_air_velocity = Vector2(300.0, 0.0)
locomotion.terrain_air_velocity.x *= exp(-LOC.AIR_DRAG_X * 0.1)
_check(locomotion.terrain_air_velocity.x > 0.0 and locomotion.terrain_air_velocity.x < 300.0, "air drag preserves forward momentum while decaying it")
func _test_grounded_reset_and_ragdoll_cleanup(rig: StickmanRig) -> void:
rig._terrain_following_enabled = true
rig._terrain_motion = RIG.TerrainMotion.GROUNDED
rig._last_grounded_walk_velocity_x = 999.0
var locomotion: StickmanLocomotion = rig.get_locomotion()
locomotion.terrain_following_enabled = true
locomotion.terrain_motion = LOC.TerrainMotion.GROUNDED
locomotion._last_grounded_walk_velocity_x = 999.0
rig._physics_process(0.0)
_check(is_zero_approx(rig._last_grounded_walk_velocity_x), "grounded idle frame clears stale walk velocity")
rig._enter_airborne(-240.0)
rig._terrain_air_velocity.y = 640.0
rig._apply_air_pose(0.1)
_check(is_zero_approx(locomotion._last_grounded_walk_velocity_x), "grounded idle frame clears stale walk velocity")
locomotion._enter_airborne(-240.0)
locomotion.terrain_air_velocity.y = 640.0
locomotion._apply_air_pose(0.1)
rig.set_ragdoll(true)
_check(rig.state == RIG.RigState.RAGDOLL, "direct ragdoll enters from ordinary airborne motion")
_check(rig._terrain_motion == RIG.TerrainMotion.DISABLED and rig._air_pose_base.is_empty(), "direct ragdoll clears airborne terrain ownership")
_check(rig._terrain_air_velocity.is_zero_approx() and is_zero_approx(rig._air_pose_weight), "direct ragdoll clears air velocity and pose weight")
_check(locomotion.terrain_motion == LOC.TerrainMotion.DISABLED and locomotion.air_pose_base.is_empty(), "direct ragdoll clears airborne terrain ownership")
_check(locomotion.terrain_air_velocity.is_zero_approx() and is_zero_approx(locomotion.air_pose_weight), "direct ragdoll clears air velocity and pose weight")
rig.snap_to_standing()
func _test_landing_timeline_and_barrier(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
var torso := rig._get_ik_marker("Torso")
var left_leg := rig._get_ik_marker("Left_Leg")
var right_leg := rig._get_ik_marker("Right_Leg")
rig._enter_airborne(0.0)
locomotion._enter_airborne(0.0)
var base_torso := torso.position
var left_hand := rig._get_ik_marker("Left_Hand")
var right_hand := rig._get_ik_marker("Right_Hand")
@@ -97,25 +101,25 @@ func _test_landing_timeline_and_barrier(rig: StickmanRig) -> void:
"Left_Leg": {"surface": left_leg.global_position + Vector2(0.0, 1200.0), "surface_valid": true},
"Right_Leg": {"surface": right_leg.global_position + Vector2(0.0, 1300.0), "surface_valid": true},
}
rig._air_fall_distance = RIG.HARD_LANDING_MIN_FALL_HEIGHT
rig._begin_landing(0.0, {"world_pos": torso.global_position}, contacts)
_check(is_equal_approx(rig._landing_compression, RIG.LANDING_COMPRESSION_MIN), "minimum hard fall height maps to minimum compression")
_check(rig._is_action_queue_blocked(), "landing blocks primary queue")
rig._update_landing_impact(RIG.LANDING_COMPRESS_DURATION)
_check(is_equal_approx(torso.position.y, base_torso.y + RIG.LANDING_COMPRESSION_MIN), "impact reaches deepest compression at 0.08s")
locomotion.air_fall_distance = LOC.HARD_LANDING_MIN_FALL_HEIGHT
locomotion._begin_landing(0.0, {"world_pos": torso.global_position}, contacts)
_check(is_equal_approx(locomotion.landing_compression, LOC.LANDING_COMPRESSION_MIN), "minimum hard fall height maps to minimum compression")
_check(locomotion.is_action_blocked(), "landing blocks primary queue")
locomotion._update_landing_impact(LOC.LANDING_COMPRESS_DURATION)
_check(is_equal_approx(torso.position.y, base_torso.y + LOC.LANDING_COMPRESSION_MIN), "impact reaches deepest compression at 0.08s")
_check(left_hand.position.y > left_hand_base.y and right_hand.position.y > right_hand_base.y, "hard landing sharply drives hands downward")
_check(left_leg.global_position.y < torso.global_position.y + 500.0 and right_leg.global_position.y < torso.global_position.y + 500.0, "landing ignores stale pre-motion solved contacts")
rig._update_landing_impact(RIG.LANDING_RECOVER_DURATION)
_check(rig._terrain_motion == RIG.TerrainMotion.GROUNDED, "landing returns to GROUNDED after 0.30s")
locomotion._update_landing_impact(LOC.LANDING_RECOVER_DURATION)
_check(locomotion.terrain_motion == LOC.TerrainMotion.GROUNDED, "landing returns to GROUNDED after 0.30s")
_check(torso.position.is_equal_approx(base_torso), "landing restores torso exactly")
_check(left_hand.position.is_equal_approx(left_hand_base) and right_hand.position.is_equal_approx(right_hand_base), "landing restores hand bases exactly")
_check(not rig._is_action_queue_blocked(), "queue barrier clears after landing")
rig._enter_airborne(0.0)
rig._air_fall_distance = RIG.HARD_LANDING_MAX_FALL_HEIGHT
rig._begin_landing(0.0, {"world_pos": torso.global_position}, contacts)
_check(is_equal_approx(rig._landing_compression, RIG.LANDING_COMPRESSION_MAX), "maximum hard fall height maps to maximum compression")
rig._cancel_landing_impact(true)
rig._restore_air_pose()
_check(not locomotion.is_action_blocked(), "queue barrier clears after landing")
locomotion._enter_airborne(0.0)
locomotion.air_fall_distance = LOC.HARD_LANDING_MAX_FALL_HEIGHT
locomotion._begin_landing(0.0, {"world_pos": torso.global_position}, contacts)
_check(is_equal_approx(locomotion.landing_compression, LOC.LANDING_COMPRESSION_MAX), "maximum hard fall height maps to maximum compression")
locomotion._cancel_landing_impact(true)
locomotion._restore_air_pose()
func _check(condition: bool, message: String) -> void:
+74
View File
@@ -0,0 +1,74 @@
extends SceneTree
const RIG_SCENE_PATH := "res://scenes/stickman_rig.tscn"
const EXPECTED_UID := "uid://dr5ef3l2var2s"
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var scene_source := FileAccess.get_file_as_string(RIG_SCENE_PATH)
_check(scene_source.contains('uid="%s"' % EXPECTED_UID), "relocated scene retains its UID")
var packed := load(RIG_SCENE_PATH) as PackedScene
_check(packed != null, "relocated rig scene loads")
if packed == null:
_finish()
return
var rig := packed.instantiate() as StickmanRig
_check(rig != null, "scene root remains StickmanRig")
root.add_child(rig)
await process_frame
await physics_frame
var locomotion := rig.get_node_or_null("RigComponents/Locomotion") as StickmanLocomotion
var ragdoll := rig.get_node_or_null("RigComponents/Ragdoll") as StickmanRagdoll
var runner := rig.get_node_or_null("RigComponents/ActionRunner") as StickmanActionRunner
_check(locomotion != null, "typed Locomotion component resolves")
_check(ragdoll != null, "typed Ragdoll component resolves")
_check(runner != null, "typed ActionRunner component resolves")
_check(rig.get_locomotion() == locomotion, "root locomotion facade exposes authored component")
var assigned: Array[Dictionary] = [{"type": "wait", "duration": 0.25}]
rig.action_queue = assigned
_check(runner.action_queue.size() == 1, "direct root action_queue assignment delegates")
_check(rig.get_queue() == assigned, "root queue query preserves compatibility")
rig.queue_action({"type": "speak", "text": "modular", "duration": 0.1})
_check(runner.action_queue.size() == 2, "root queue mutation delegates")
rig.set_ragdoll(true)
await physics_frame
_check(rig.state == StickmanRig.RigState.RAGDOLL, "root remains ragdoll state authority")
_check(ragdoll.get_bodies().size() == 10, "ragdoll component owns ten physics bodies")
rig.snap_to_standing()
await physics_frame
_check(rig.state == StickmanRig.RigState.ANIMATED, "snap facade returns to ANIMATED")
_check(ragdoll.get_bodies().is_empty(), "snap destroys component-owned ragdoll bodies")
rig.queue_free()
await process_frame
await process_frame
var orphan := root.find_child("RagdollBodyContainer", true, false)
_check(orphan == null, "freeing rig leaves no ragdoll container orphan")
_finish()
func _check(condition: bool, label: String) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
func _finish() -> void:
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
@@ -0,0 +1 @@
uid://c8ed0hlc7bl6r