Implement Phase 17a Prop Roles UI/UX Specification and associated functionality

- Added UI/UX specification for prop roles and set down mechanics in phase 17a.
- Created `PropStageRoles` script to manage normalized behavior for props, including depth, motion, and collision settings.
- Introduced `StagePhysicsPolicy` for shared physics configurations related to props and actors.
- Developed comprehensive tests for prop roles, including default settings, legacy migration, serialization, and runtime behavior.
- Ensured proper handling of set down actions, including clearance and orientation preservation.
This commit is contained in:
2026-10-09 23:11:34 -04:00
parent 3f66cc3195
commit 1d4a71525a
52 changed files with 20051 additions and 109 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ func _test_custom_prop_lifecycle() -> void:
var authored_scale: Vector2 = prop.scale
_check(prop.freeze, "custom prop is frozen in EDIT")
stage.set_mode(stage.StageMode.PLAY)
_check(not prop.freeze, "custom prop unfreezes in PLAY")
_check(prop.freeze and prop.runtime_role == PROP_BLOCK.RuntimeRole.START_STILL, "new custom prop starts still in PLAY")
var play_scene: Dictionary = stage._collect_scene_data()
var play_entry: Dictionary = (play_scene.get("objects", []) as Array)[0]
var saved_scale: Vector2 = Vector2(float((play_entry.get("scale", {}) as Dictionary).get("x", 0.0)), float((play_entry.get("scale", {}) as Dictionary).get("y", 0.0)))
+6 -3
View File
@@ -113,6 +113,7 @@ func _run() -> void:
prop.polygon_points = PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(10, 10), Vector2(-10, 10)])
prop.freeze = true
root.add_child(prop)
prop.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_STILL)
await process_frame
var gizmos: StageGizmos = STAGE_GIZMOS.new()
root.add_child(gizmos)
@@ -128,6 +129,7 @@ func _run() -> void:
gizmos.drag_to(prop.global_position + (corner - prop.global_position) * 2.0)
gizmos.end_drag()
_check(is_equal_approx(prop.scale.x / initial_scale.x, 2.0) and is_equal_approx(prop.scale.y / initial_scale.y, 2.0), "corner drag applies uniform scale")
prop.begin_play_instance_scale()
await physics_frame
var space: PhysicsDirectSpaceState2D = root.get_world_2d().direct_space_state
var polygon_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
@@ -138,8 +140,7 @@ func _run() -> void:
var polygon_outside_hits: Array[Dictionary] = space.intersect_point(polygon_outside)
_check(not polygon_inside_hits.is_empty() and polygon_outside_hits.is_empty(), "scaled polygon collision matches its visual extent")
prop.global_rotation = deg_to_rad(20.0)
prop.begin_play_instance_scale()
prop.freeze = false
prop.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
for frame: int in 3:
await physics_frame
var rotated_local_inside: Vector2 = Vector2(19.0, 0.0).rotated(prop.global_rotation)
@@ -154,6 +155,7 @@ func _run() -> void:
circle.scale = Vector2(2.0, 2.0)
circle.freeze = true
root.add_child(circle)
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_STILL)
await physics_frame
var circle_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
@@ -161,7 +163,7 @@ func _run() -> void:
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "scaled circle collision matches its visual extent")
circle.begin_play_instance_scale()
circle.freeze = false
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
for frame: int in 3:
await physics_frame
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
@@ -171,6 +173,7 @@ func _run() -> void:
circle.end_play_instance_scale()
circle.scale = Vector2(2.0, 1.0)
circle.begin_play_instance_scale()
circle.apply_runtime_role(PROP_BLOCK.RuntimeRole.START_PHYSICS)
await physics_frame
var ellipse_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
ellipse_inside.position = circle.global_position + Vector2(0.0, 9.0)
+307
View File
@@ -0,0 +1,307 @@
extends SceneTree
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd")
const PROP_INTERACTOR_SCRIPT := preload("res://scripts/characters/stickman/stickman_prop_interactor.gd")
const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_role_defaults_and_matrix()
_test_legacy_migration()
_test_set_down_serialization()
_test_unknown_preview()
await _test_runtime_profiles()
await _test_start_still_support_contact()
await _test_set_down_clearance_and_release()
await _test_set_down_preserves_pickup_orientation()
await _test_set_down_low_reach_pose()
await _test_set_down_prop_follows_approaching_actor()
print("----------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d checks)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d checks" % [_failures, _checks])
quit(1)
func _expect(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
push_error("Phase 17a check failed: " + message)
func _test_role_defaults_and_matrix() -> void:
var roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
_expect(roles["depth"] == PROP_STAGE_ROLES.DEPTH_BEHIND, "new props start behind characters")
_expect(roles["start_motion"] == PROP_STAGE_ROLES.START_STILL, "new props start still")
_expect(not bool(roles["can_pick_up"]), "props without a grip are not pickable")
_expect(bool(roles["released_blocks_stickmen"]) and bool(roles["released_bumps_other_props"]), "released contacts default On")
var table_roles: Dictionary = roles.duplicate(true)
table_roles["holds_other_props"] = true
var pot_roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
_expect(PROP_STAGE_ROLES.props_should_collide(table_roles, 1, pot_roles, 2), "start still support catches a physics prop")
table_roles["holds_other_props"] = false
_expect(not PROP_STAGE_ROLES.props_should_collide(table_roles, 1, pot_roles, 2), "start still prop without support capability does not catch props")
var released_roles: Dictionary = pot_roles.duplicate(true)
released_roles["released_bumps_other_props"] = false
_expect(not PROP_STAGE_ROLES.props_should_collide(released_roles, 4, table_roles, 2), "released other prop Off wins over dynamic contacts")
_expect(PROP_STAGE_ROLES.props_should_collide(pot_roles, 2, table_roles, 2), "two physics props can collide")
_expect(not PROP_STAGE_ROLES.props_should_collide(pot_roles, 3, table_roles, 2), "held props never collide")
func _test_legacy_migration() -> void:
var legacy := {
"version": "1.0",
"objects": [
{"file_id": "character_a", "type": "stickman"},
{"file_id": "prop_a", "type": "prop"},
{"file_id": "character_b", "type": "stickman"},
],
}
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(legacy)
var prop_entry: Dictionary = migrated["objects"][1]
var roles: Dictionary = prop_entry["roles"]
_expect(migrated["version"] == "1.2", "old stage version migrates to 1.2")
_expect(prop_entry["stage_object_id"] == "prop_a", "1.0 prop IDs migrate from file IDs")
_expect(String(roles["start_motion"]) == PROP_STAGE_ROLES.START_PHYSICS, "legacy prop keeps dynamic start behavior")
_expect(bool(roles["blocks_stickmen"]) and bool(roles["legacy_draw_order"]), "legacy prop keeps collisions and draw order")
var current_incomplete := {"version": "1.2", "objects": [{"file_id": "p", "type": "prop", "stage_object_id": "p"}]}
var repaired: Dictionary = STAGE_SCENE_IO._migrate_to_current(current_incomplete)
_expect(bool(repaired["objects"][0]["roles"]["legacy_draw_order"]), "1.2 prop missing roles receives legacy behavior")
var invalid := {"objects": [{"file_id": "p", "type": "prop", "stage_object_id": "p", "roles": {"depth": 5}}]}
_expect(not bool(STAGE_SCENE_IO._validate_root(invalid)["ok"]), "malformed present role data is rejected")
func _test_set_down_serialization() -> void:
var position := Vector2(420.0, -260.0)
var encoded_queue: Dictionary = STAGE_SCENE_IO.encode_queue([{"type": "set_down_prop", "target_position": position}])
var queue_entries: Array = encoded_queue["actions"]
var decoded_queue: Dictionary = STAGE_SCENE_IO.decode_queue(queue_entries)
var queue_action: Dictionary = decoded_queue["actions"][0]
_expect(queue_action["type"] == "set_down_prop" and queue_action["target_position"] == position, "Set Down queue target round-trips")
var flat_action := {"type": "set_down_prop", "target_position": position}
var rule_action: Dictionary = ACTION_REGISTRY.to_rule_action(flat_action, 7)
var rules: Array[Dictionary] = [{
"id": 1,
"trigger": {"type": "speech_finished", "source": 7, "target": -1, "params": {}},
"actions": [rule_action],
}]
var runtime_to_file := {7: "actor_file"}
var encoded_rules: Dictionary = STAGE_SCENE_IO.encode_rules(rules, runtime_to_file)
var file_to_runtime := {"actor_file": 7}
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(encoded_rules["rules"], file_to_runtime, {})
var decoded_action: Dictionary = decoded_rules["rules"][0]["actions"][0]
_expect(decoded_action["type"] == "set_down_prop" and decoded_action["params"]["target_position"] == position, "Set Down rule target round-trips")
_expect(String(ACTION_REGISTRY.summarize(flat_action)).contains("Set Down Prop"), "Set Down queue summary is available")
func _test_unknown_preview() -> void:
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
root.add_child(interactor)
var empty_prefix: Array[Dictionary] = []
var preview: Dictionary = interactor.call("preview_set_down", Vector2(30.0, 60.0), empty_prefix)
_expect(not bool(preview.get("known", true)), "unknown held prop preview remains conditional")
_expect(String(preview.get("reason", "")) == "Prop depends on earlier actions", "unknown preview shows the conditional cue")
interactor.queue_free()
func _test_runtime_profiles() -> void:
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
root.add_child(prop)
await process_frame
var roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
roles["blocks_stickmen"] = true
roles["released_blocks_stickmen"] = false
prop.set_stage_roles(roles)
prop.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
_expect(prop.freeze and prop.runtime_role == PropBlock.RuntimeRole.START_STILL, "start still profile freezes the body")
_expect((prop.collision_layer & STAGE_PHYSICS.PROP_LAYER) != 0, "start still prop keeps its physical shape active")
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
_expect(prop.freeze and prop.collision_layer == 0 and prop.collision_mask == 0, "held profile removes physics contacts")
prop.apply_runtime_role(PropBlock.RuntimeRole.RELEASED)
_expect(not prop.freeze and prop.runtime_role == PropBlock.RuntimeRole.RELEASED, "released profile wakes a dynamic body")
_expect((prop.collision_layer & STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER) == 0, "released actor collision setting is independent of initial blocking")
prop.queue_free()
await process_frame
func _test_start_still_support_contact() -> void:
var world := Node2D.new()
root.add_child(world)
var support: PropBlock = PROP_BLOCK_SCRIPT.new()
support.position = Vector2(0.0, 100.0)
support.polygon_points = PackedVector2Array([Vector2(-60.0, -10.0), Vector2(60.0, -10.0), Vector2(60.0, 10.0), Vector2(-60.0, 10.0)])
world.add_child(support)
await process_frame
var support_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
support_roles["holds_other_props"] = true
support.set_stage_roles(support_roles)
support.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
var falling: PropBlock = PROP_BLOCK_SCRIPT.new()
falling.position = Vector2(0.0, 55.0)
falling.polygon_points = PackedVector2Array([Vector2(-10.0, -10.0), Vector2(10.0, -10.0), Vector2(10.0, 10.0), Vector2(-10.0, 10.0)])
world.add_child(falling)
await process_frame
var falling_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
falling_roles["start_motion"] = PROP_STAGE_ROLES.START_PHYSICS
falling.set_stage_roles(falling_roles)
falling.apply_runtime_role(PropBlock.RuntimeRole.START_PHYSICS)
var contact: Dictionary = {"other": false}
falling.collided.connect(func(other: Node, _normal: Vector2, _position: Vector2) -> void:
if other == support:
contact["other"] = true
)
for _frame: int in 45:
await physics_frame
_expect(support.freeze and support.freeze_mode == RigidBody2D.FREEZE_MODE_STATIC, "start still support remains a static frozen collider")
_expect(bool(contact["other"]) and falling.get_colliding_bodies().has(support), "dynamic prop makes an ordinary body contact with the frozen support")
_expect(falling.global_position.y >= 78.0 and falling.global_position.y <= 82.0, "dynamic prop rests on the frozen support geometry")
world.queue_free()
await process_frame
func _test_set_down_clearance_and_release() -> void:
var world := Node2D.new()
root.add_child(world)
var support: PropBlock = PROP_BLOCK_SCRIPT.new()
support.position = Vector2(0.0, 100.0)
support.polygon_points = PackedVector2Array([Vector2(-100.0, -50.0), Vector2(100.0, -50.0), Vector2(100.0, 50.0), Vector2(-100.0, 50.0)])
world.add_child(support)
await process_frame
var support_roles: Dictionary = PROP_STAGE_ROLES.new_placement(false)
support_roles["holds_other_props"] = true
support.set_stage_roles(support_roles)
support.apply_runtime_role(PropBlock.RuntimeRole.START_STILL)
var held: PropBlock = PROP_BLOCK_SCRIPT.new()
held.polygon_points = PackedVector2Array([Vector2(-10.0, -10.0), Vector2(10.0, -10.0), Vector2(10.0, 10.0), Vector2(-10.0, 10.0)])
world.add_child(held)
await process_frame
var held_roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
held.set_stage_roles(held_roles)
held.apply_runtime_role(PropBlock.RuntimeRole.HELD)
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
world.add_child(interactor)
await physics_frame
var requested := Vector2(0.0, 120.0)
var resolved: Dictionary = interactor.call("_resolve_set_down_release_transform", held, requested)
_expect(bool(resolved.get("valid", false)), "Set Down raises a point embedded in a support prop")
var release_transform: Transform2D = resolved.get("release_transform", Transform2D.IDENTITY)
_expect(float(resolved.get("clearance_lift", 0.0)) > 50.0 and release_transform.origin.y < requested.y, "support clearance moves only the release origin upward")
_expect(is_equal_approx(release_transform.origin.x, requested.x), "support clearance preserves the clicked horizontal point")
var above_support: Vector2 = Vector2(0.0, 25.0)
var unchanged: Dictionary = interactor.call("_resolve_set_down_release_transform", held, above_support)
var unchanged_transform: Transform2D = unchanged.get("release_transform", Transform2D.IDENTITY)
_expect(bool(unchanged.get("valid", false)) and unchanged_transform.origin.is_equal_approx(above_support), "a clear point above the support stays exactly where clicked")
var open_air := Vector2(300.0, -200.0)
var open_air_result: Dictionary = interactor.call("_resolve_set_down_release_transform", held, open_air)
var open_air_transform: Transform2D = open_air_result.get("release_transform", Transform2D.IDENTITY)
_expect(bool(open_air_result.get("valid", false)) and open_air_transform.origin.is_equal_approx(open_air), "an open-air release stays exactly where clicked")
_expect(held.collision_layer == 0 and held.collision_mask == 0, "Set Down keeps the object non-collidable while held")
if bool(resolved.get("valid", false)):
held.global_transform = release_transform
held.apply_runtime_role(PropBlock.RuntimeRole.RELEASED)
held.sleeping = false
for _frame: int in 45:
await physics_frame
_expect(held.runtime_role == PropBlock.RuntimeRole.RELEASED and (held.collision_layer & STAGE_PHYSICS.PROP_LAYER) != 0, "release enables the configured prop collisions")
_expect(held.get_colliding_bodies().has(support), "released object falls onto the support")
_expect(absf(held.global_position.y - 40.0) <= 3.0, "released object settles on top of the crate")
interactor.queue_free()
world.queue_free()
await process_frame
func _test_set_down_low_reach_pose() -> void:
var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
root.add_child(rig)
await physics_frame
var interactor := rig.get_node(NodePath("RigComponents/PropInteractor"))
var torso_marker := rig.get_node(NodePath("IK_Targets/Torso")) as Marker2D
var upper_leg := rig.get_node(NodePath("Skeleton2D/Torso/RightUpperLeg")) as Bone2D
var start_position := torso_marker.position
var start_rotation := torso_marker.rotation
var start_leg_rotation := upper_leg.rotation
interactor.set("_set_down_facing_left", false)
interactor.call("_prepare_set_down_crouch", rig.to_global(Vector2(60.0, 430.0)))
interactor.call("_update_pickup_crouch", 0.5)
await physics_frame
_expect(torso_marker.position.y > start_position.y and torso_marker.rotation != start_rotation, "low Set Down reach lowers and leans the torso smoothly")
_expect(absf(wrapf(upper_leg.rotation - start_leg_rotation, -PI, PI)) > 0.01, "the lowered torso lets leg IK bend a knee")
interactor.call("_update_pickup_crouch", 0.0)
await physics_frame
_expect(torso_marker.position.is_equal_approx(start_position), "Set Down body recovery returns the torso to its starting pose")
rig.queue_free()
await process_frame
func _test_set_down_preserves_pickup_orientation() -> void:
var world := Node2D.new()
root.add_child(world)
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
world.add_child(prop)
await process_frame
prop.global_transform = Transform2D(PI * 0.5, Vector2(0.0, -100.0))
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true))
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
var pickup_transform := prop.global_transform
var interactor: Node = PROP_INTERACTOR_SCRIPT.new()
world.add_child(interactor)
interactor.set("_held_prop", prop)
interactor.set("_pickup_transform", pickup_transform)
interactor.set("_pickup_transform_valid", true)
prop.global_transform = Transform2D(0.0, Vector2(0.0, -100.0))
var resolved: Dictionary = interactor.call("_resolve_set_down_release_transform", prop, Vector2(1000.0, -300.0))
var resolved_transform: Transform2D = resolved.get("release_transform", Transform2D.IDENTITY)
_expect(bool(resolved.get("valid", false)), "orientation-preserving Set Down target remains valid")
_expect(absf(wrapf(resolved_transform.get_rotation() - pickup_transform.get_rotation(), -PI, PI)) < 0.001, "Set Down restores the prop's pickup orientation")
interactor.set("_held_anchor", {"position": Vector2(20.0, 0.0)})
interactor.set("_set_down_facing_left", false)
interactor.set("_set_down_reach_start_transform", Transform2D(PI, Vector2.ZERO))
var hand_position := Vector2(80.0, 120.0)
var eased_transform: Transform2D = interactor.call("_set_down_reach_attachment_transform", hand_position, 1.0)
_expect(absf(wrapf(eased_transform.get_rotation() - pickup_transform.get_rotation(), -PI, PI)) < 0.001, "Set Down reach smoothly returns the prop to its pickup rotation")
_expect((eased_transform * Vector2(20.0, 0.0)).distance_to(hand_position) < 0.001, "restored prop orientation keeps its grip anchor at the hand")
interactor.queue_free()
world.queue_free()
await process_frame
func _test_set_down_prop_follows_approaching_actor() -> void:
var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
root.add_child(rig)
await physics_frame
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
prop.polygon_points = PackedVector2Array([Vector2(-20.0, -20.0), Vector2(20.0, -20.0), Vector2(20.0, 20.0), Vector2(-20.0, 20.0)])
prop.position = Vector2(1400.0, 900.0)
root.add_child(prop)
await process_frame
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true))
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
var interactor := rig.get_node(NodePath("RigComponents/PropInteractor"))
interactor.set("_held_prop", prop)
interactor.set("_held_anchor", {"position": Vector2.ZERO})
interactor.set("_phase", PROP_INTERACTOR_SCRIPT.Phase.SET_DOWN_APPROACHING)
var before := prop.global_position
interactor.call("tick", 0.016)
_expect(prop.global_position.distance_to(before) > 500.0, "a held prop follows the hand while the actor walks to Set Down")
_expect(prop.runtime_role == PropBlock.RuntimeRole.HELD and prop.collision_layer == 0 and prop.collision_mask == 0, "the following prop remains collision-free during the approach")
interactor.set("_held_prop", null)
interactor.set("_phase", PROP_INTERACTOR_SCRIPT.Phase.IDLE)
prop.queue_free()
rig.queue_free()
await process_frame
+1
View File
@@ -0,0 +1 @@
uid://ewdgod63htwq
+39 -17
View File
@@ -1,6 +1,7 @@
extends SceneTree
const STAGE_SCENE := preload("res://scenes/stage/sandbox_stage.tscn")
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
var _checks := 0
var _failures := 0
@@ -23,7 +24,7 @@ func _run() -> void:
_test_collision_proxy_geometry(rig)
await _test_collision_proxy_physics_alignment(rig)
_test_reactions(rig)
await _test_direct_restores_animated_pose(stage, rig)
await _test_direct_pause_and_edit_restore(stage, rig)
_test_ghost_collision(stage)
await _test_stage_contact_routing(stage, rig)
_test_ragdoll_transitions(rig)
@@ -54,7 +55,7 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
_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.collision_layer == STAGE_PHYSICS.ACTOR_LAYER and body.collision_mask == STAGE_PHYSICS.actor_mask(), "%s proxy starts with the actor policy" % 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)
@@ -90,9 +91,9 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
empty_visual.free()
rig.set_collision_bodies_enabled(false)
_check(_all_proxy_layers_equal(rig, 0), "public collision hook disables every proxy")
_check(_all_proxy_layers_equal(rig, 0, 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")
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "public collision hook re-enables every proxy with the actor policy")
var sizes_before := _proxy_shape_sizes(rig)
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
@@ -181,7 +182,7 @@ func _test_reactions(rig: StickmanRig) -> void:
_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:
func _test_direct_pause_and_edit_restore(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()
@@ -194,18 +195,37 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi
torso_marker.rotation = PI
var head_marker := rig.get_node("IK_Targets/Head") as Marker2D
head_marker.position += Vector2(120.0, 80.0)
var direct_position: Vector2 = rig.global_position
var direct_rotation: float = rig.global_rotation
var direct_facing: int = rig.get_facing_profile()
var direct_markers: Dictionary = {}
for marker_name: String in StickmanRagdoll.STAND_POSE:
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
direct_markers[marker_name] = {"pos": marker.position, "rot": marker.rotation}
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")
_check(rig.global_position.distance_to(direct_position) < 0.01, "PLAY to DIRECT preserves the live stickman position while paused")
_check(absf(angle_difference(direct_rotation, rig.global_rotation)) < 0.001, "PLAY to DIRECT preserves the live stickman rotation while paused")
_check(rig.get_facing_profile() == direct_facing, "PLAY to DIRECT preserves the live facing profile while paused")
for marker_name: String in StickmanRagdoll.STAND_POSE:
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
var paused_pose: Dictionary = direct_markers[marker_name]
_check(marker.position.distance_to(paused_pose["pos"] as Vector2) < 0.01, "%s marker stays in its live pose while DIRECT is paused" % marker_name)
_check(absf(angle_difference(float(paused_pose["rot"]), marker.rotation)) < 0.001, "%s marker keeps its live rotation while DIRECT is paused" % marker_name)
stage.set_mode(stage.StageMode.EDIT)
for _frame in 4:
await physics_frame
await process_frame
_check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "EDIT/STOP reset restores the authored stickman position")
_check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "EDIT/STOP reset restores the authored stickman rotation")
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "EDIT/STOP reset 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")
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored pose after EDIT/STOP reset" % marker_name)
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "EDIT/STOP reset restores the authored torso marker rotation")
func _test_ghost_collision(stage: Node2D) -> void:
@@ -213,12 +233,14 @@ func _test_ghost_collision(stage: Node2D) -> void:
if ghost == null:
return
stage._configure_ghost_collision(ghost)
_check(_all_proxy_layers_equal(ghost, 0), "stickman placement ghosts have no collision")
_check(_all_proxy_layers_equal(ghost, 0, 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)
for _frame: int in 3:
await physics_frame
var prop := PropBlock.new()
prop.position = Vector2(50.0, 25.0)
prop.linear_velocity = Vector2(400.0, 0.0)
@@ -332,26 +354,26 @@ func _test_ragdoll_transitions(rig: StickmanRig) -> void:
_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(_all_proxy_layers_equal(rig, 0, 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")
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "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")
_check(_all_proxy_layers_equal(rig, 0, 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")
_check(_all_proxy_layers_equal(rig, STAGE_PHYSICS.ACTOR_LAYER, STAGE_PHYSICS.actor_mask()), "snap restores animated collision proxies")
func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
func _all_proxy_layers_equal(rig: StickmanRig, expected_layer: int, expected_mask: int) -> bool:
for body: AnimatableBody2D in rig.get_locomotion().collision_bodies.values():
if body.collision_layer != expected or body.collision_mask != expected:
if body.collision_layer != expected_layer or body.collision_mask != expected_mask:
return false
return true