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
+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