Files
stickman/tests/test_phase45_collision_bodies.gd
T

387 lines
20 KiB
GDScript

extends SceneTree
const STAGE_SCENE := preload("res://scenes/stage/sandbox_stage.tscn")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var stage := STAGE_SCENE.instantiate() as Node2D
root.add_child(stage)
await process_frame
var rig := _spawn_rig(stage, Vector2.ZERO)
if rig == null:
_finish()
return
_test_collision_proxy_geometry(rig)
await _test_collision_proxy_physics_alignment(rig)
_test_reactions(rig)
await _test_direct_restores_animated_pose(stage, rig)
_test_ghost_collision(stage)
await _test_stage_contact_routing(stage, rig)
_test_ragdoll_transitions(rig)
stage.queue_free()
await process_frame
_finish()
func _spawn_rig(stage: Node2D, position: Vector2) -> StickmanRig:
var rig := stage._spawner.spawn("stickman", position) as StickmanRig
_check(rig != null, "stage spawns a stickman rig")
if rig != null:
stage._finalize_placed_node(rig)
return rig
func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
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
_check(body.name == "CollisionBody", "%s proxy uses the standard name" % part_name)
_check(not body.sync_to_physics, "%s proxy uses explicit transform synchronization" % part_name)
_check(body.top_level, "%s proxy is isolated from RemoteTransform2D parent updates" % part_name)
_check(body.collision_layer == 1 and body.collision_mask == 1, "%s proxy starts enabled" % part_name)
_check(body.get_meta("stickman_rig", null) == rig, "%s proxy stores its rig" % part_name)
_check(String(body.get_meta("part_name", "")) == part_name, "%s proxy stores its part name" % part_name)
var shape_node := body.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
_check(shape_node != null, "%s proxy owns a collision shape" % part_name)
if shape_node == null:
continue
if part_name == "head":
var circle := shape_node.shape as CircleShape2D
_check(circle != null, "head uses a circle")
if circle != null:
_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 = 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")
_check(shape_node.position.y < 0.0, "head collider is above its neck attachment")
else:
var capsule := shape_node.shape as CapsuleShape2D
_check(capsule != null, "%s uses a capsule" % part_name)
if capsule == null:
continue
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 = 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()
rig.set_collision_bodies_enabled(false)
_check(_all_proxy_layers_equal(rig, 0), "public collision hook disables every proxy")
rig.set_collision_bodies_enabled(true)
_check(_all_proxy_layers_equal(rig, 1), "public collision hook re-enables every proxy")
var sizes_before := _proxy_shape_sizes(rig)
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
_check(_proxy_shape_sizes(rig) == sizes_before, "LEFT mirroring preserves symmetric proxy sizes")
rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
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
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 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 := 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
var server_transform := PhysicsServer2D.body_get_state(body.get_rid(), PhysicsServer2D.BODY_STATE_TRANSFORM) as Transform2D
_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.get_locomotion().cancel_walk()
rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
func _test_reactions(rig: StickmanRig) -> void:
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(locomotion.impact_reactions.is_empty(), "invalid reactions are ignored")
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((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((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((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((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 = 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))
(locomotion.impact_reactions["LeftLowerArm"] as Dictionary)["strength"] = 0.25
rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 40.0)
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 = locomotion.impact_reactions["LeftLowerArm"]
_check(signf(float(lower["angle"])) != signf(strong_angle), "equal capped hit may replace the reaction direction")
var lower_bone := locomotion.reaction_bones["LeftLowerArm"] as Bone2D
var base_rotation := lower_bone.rotation
var max_offset := 0.0
for frame in 30:
locomotion.apply_impact_reactions(1.0 / 60.0)
max_offset = maxf(max_offset, absf(angle_difference(base_rotation, lower_bone.rotation)))
_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")
func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void:
var authored: Dictionary = stage._authored[rig.get_instance_id()]
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
var authored_markers := ragdoll.get_spawn_pose()
stage.set_mode(stage.StageMode.PLAY)
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
rig.global_position += Vector2(220.0, -90.0)
rig.global_rotation = PI
var torso_marker := rig.get_node("IK_Targets/Torso") as Marker2D
torso_marker.position = Vector2(75.0, -130.0)
torso_marker.rotation = PI
var head_marker := rig.get_node("IK_Targets/Head") as Marker2D
head_marker.position += Vector2(120.0, 80.0)
stage.set_mode(stage.StageMode.DIRECT)
for _frame in 4:
await physics_frame
await process_frame
_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 StickmanRagdoll.STAND_POSE:
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
var expected: Dictionary = authored_markers[marker_name]
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored spawn pose after PLAY" % marker_name)
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "torso marker restores its authored rotation after PLAY")
func _test_ghost_collision(stage: Node2D) -> void:
var ghost := _spawn_rig(stage, Vector2(1000.0, 0.0))
if ghost == null:
return
stage._configure_ghost_collision(ghost)
_check(_all_proxy_layers_equal(ghost, 0), "stickman placement ghosts have no collision")
ghost.queue_free()
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)
stage._world.add_child(prop)
await process_frame
_check(prop.continuous_cd == RigidBody2D.CCD_MODE_CAST_SHAPE, "moving props use shape-cast CCD for thin limbs")
var emitted_collisions: Array[Dictionary] = []
var emitted_separations: Array[Node] = []
prop.collided.connect(func(other: Node, normal: Vector2, position: Vector2) -> void:
emitted_collisions.append({"other": other, "normal": normal, "position": position})
)
prop.separated.connect(func(other: Node) -> void:
emitted_separations.append(other)
)
var fallback_body := Node2D.new()
fallback_body.position = prop.position + Vector2.RIGHT * 20.0
stage._world.add_child(fallback_body)
prop._on_body_entered(fallback_body)
prop._on_body_exited(fallback_body)
_check(emitted_collisions.size() == 1, "PropBlock emits its extended collided signal")
if emitted_collisions.size() == 1:
_check((emitted_collisions[0]["normal"] as Vector2).is_equal_approx(Vector2.RIGHT), "PropBlock fallback normal points prop-to-other")
_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 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,
"trigger": {
"type": "collided",
"source": rig.get_instance_id(),
"target": prop.get_instance_id(),
"params": {},
},
"actions": [{"type": "wait", "target": rig.get_instance_id(), "params": {"duration": 10.0}}],
}
stage._event_rules.clear()
stage._event_rules.append(rule)
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(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(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(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")
var forward_event := {"type": "collided", "source": rig, "target": prop, "position": Vector2.ZERO, "action": {}}
var reverse_event := {"type": "collided", "source": prop, "target": rig, "position": Vector2.ZERO, "action": {}}
_check(stage._rule_matches(rule, forward_event), "collided rules preserve rig-to-prop ordering")
_check(not stage._rule_matches(rule, reverse_event), "collided rule matching is not bidirectional")
var generic_rule := {
"id": 4501,
"trigger": {
"type": "collided",
"source": prop.get_instance_id(),
"target": fallback_body.get_instance_id(),
"params": {},
},
"actions": [{"type": "wait", "target": rig.get_instance_id(), "params": {"duration": 10.0}}],
}
stage._event_rules.clear()
stage._event_rules.append(generic_rule)
var before_generic: int = runner._run_queue.size()
stage._on_prop_collided(fallback_body, Vector2.RIGHT, Vector2(13.0, 20.0), prop)
_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: int = runner._run_queue.size()
stage._on_prop_collided(invalid_proxy, Vector2.RIGHT, Vector2.ZERO, prop)
_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)
stage._world.add_child(ragdoll_proxy)
var before_suppressed := rig.queue_size()
stage._on_prop_collided(ragdoll_proxy, Vector2.RIGHT, Vector2.ZERO, prop)
_check(rig.queue_size() == before_suppressed, "ragdoll contacts dispatch no rule")
fallback_body.queue_free()
invalid_proxy.queue_free()
ragdoll_proxy.queue_free()
prop.queue_free()
stage._event_rules.clear()
rig.stop_queue()
rig.clear_queue()
func _test_ragdoll_transitions(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
_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(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(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")
_check(_all_proxy_layers_equal(rig, 0), "recovery interruption disables proxies again")
rig.snap_to_standing()
_check(rig.state == StickmanRig.RigState.ANIMATED, "snap returns the rig to animated")
_check(_all_proxy_layers_equal(rig, 1), "snap restores animated collision proxies")
func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
for body: AnimatableBody2D in rig.get_locomotion().collision_bodies.values():
if body.collision_layer != expected or body.collision_mask != expected:
return false
return true
func _proxy_shape_sizes(rig: StickmanRig) -> Dictionary:
var sizes := {}
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
else:
var capsule := shape_node.shape as CapsuleShape2D
sizes[part_name] = Vector2(capsule.radius, capsule.height)
return sizes
func _check(condition: bool, message: String) -> void:
_checks += 1
if not condition:
_failures += 1
push_error("FAIL: " + message)
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)