- Added a new specification for collision bodies, detailing the implementation requirements and corrections needed in the StickmanRig, PropBlock, and SandboxStage scripts. - Created a comprehensive test suite for collision bodies, including tests for proxy geometry, physics alignment, reaction handling, ghost collision, and stage contact routing. - Ensured that the collision system correctly handles ragdoll transitions and maintains the integrity of existing event rules. - Introduced new methods in StickmanRig for managing collision bodies and applying reactions based on impact direction and speed. - Updated PropBlock to emit signals for collisions and separations, preserving the order of events for stickman and prop interactions.
377 lines
19 KiB
GDScript
377 lines
19 KiB
GDScript
extends SceneTree
|
|
|
|
const STAGE_SCENE := preload("res://scenes/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:
|
|
_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
|
|
_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 >= rig.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)
|
|
_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(rig._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)
|
|
_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:
|
|
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()
|
|
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]
|
|
var visual := rig.get_node_or_null(NodePath("Body/" + String(definition["visual"]))) as Node2D
|
|
var body := rig._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._cancel_walking()
|
|
rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
|
|
|
|
|
func _test_reactions(rig: StickmanRig) -> void:
|
|
rig._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")
|
|
|
|
var head_proxy := rig._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()
|
|
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()
|
|
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()
|
|
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()
|
|
|
|
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", {})
|
|
_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
|
|
rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 40.0)
|
|
lower = rig._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"]
|
|
_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 base_rotation := lower_bone.rotation
|
|
var max_offset := 0.0
|
|
for frame in 30:
|
|
rig._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(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()]
|
|
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 rig.STAND_POSE:
|
|
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
|
|
var expected: Dictionary = rig.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")
|
|
|
|
|
|
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:
|
|
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 lower := rig._collision_bodies["left_lower_arm"] as AnimatableBody2D
|
|
var upper := rig._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 initial_queue_size := rig.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")
|
|
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")
|
|
|
|
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")
|
|
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 := rig.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")
|
|
|
|
var invalid_proxy := Node2D.new()
|
|
invalid_proxy.set_meta("stickman_rig", rig)
|
|
stage._world.add_child(invalid_proxy)
|
|
var before_invalid := rig.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")
|
|
|
|
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:
|
|
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
|
|
_check(not rig._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")
|
|
|
|
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")
|
|
|
|
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._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 := {}
|
|
for part_name: String in rig._collision_bodies:
|
|
var body := rig._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)
|