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:
@@ -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)")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user