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
+95
View File
@@ -12,6 +12,8 @@ extends RigidBody2D
const PropVisuals := preload("res://scripts/props/prop_visuals.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -20,6 +22,7 @@ const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
enum ShapeType { POLYGON, CIRCLE }
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
enum RuntimeRole { EDIT, START_STILL, START_PHYSICS, HELD, RELEASED }
## Stable library provenance used by Sandbox Stage scene files.
var spawn_id: String = ""
@@ -30,6 +33,8 @@ var stage_object_id: String = ""
var interaction_anchors: Dictionary = {}
var interaction_reserved_by: int = 0
var interaction_owner_id: int = 0
var stage_roles: Dictionary = {}
var runtime_role: RuntimeRole = RuntimeRole.EDIT
# ---------------------------------------------------------------------------
# Child node names
@@ -115,6 +120,7 @@ var _play_scale_active: bool = false
## `other` is the body that entered contact (Phase 4 trigger source).
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
signal separated(other: Node)
signal physics_profile_changed
# ---------------------------------------------------------------------------
# Lifecycle
@@ -124,6 +130,9 @@ func _ready() -> void:
_ensure_children()
_apply_shape()
_apply_style()
if stage_roles.is_empty():
stage_roles = PROP_STAGE_ROLES.new_placement(false)
apply_runtime_role(RuntimeRole.EDIT)
# Enable contact reporting so the body_entered signal fires (Phase 4).
contact_monitor = true
max_contacts_reported = MAX_CONTACTS_REPORTED
@@ -134,6 +143,84 @@ func _ready() -> void:
body_exited.connect(_on_body_exited)
func set_stage_roles(roles: Dictionary) -> void:
stage_roles = PROP_STAGE_ROLES.normalize(roles, PROP_STAGE_ROLES.new_placement(has_usable_grip()))
apply_stage_depth()
if runtime_role != RuntimeRole.EDIT:
apply_runtime_role(runtime_role)
else:
physics_profile_changed.emit()
func apply_stage_depth() -> void:
var normalized: Dictionary = PROP_STAGE_ROLES.normalize(stage_roles)
stage_roles = normalized
z_as_relative = false
if bool(normalized.get("legacy_draw_order", false)):
z_index = 0
else:
z_index = -1 if String(normalized.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)) == PROP_STAGE_ROLES.DEPTH_BEHIND else 1
func apply_runtime_role(role: RuntimeRole) -> void:
runtime_role = role
match role:
RuntimeRole.EDIT:
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
freeze = true
collision_layer = 0
collision_mask = 0
RuntimeRole.START_STILL:
freeze_mode = RigidBody2D.FREEZE_MODE_STATIC
freeze = true
collision_layer = STAGE_PHYSICS.prop_layer(is_actor_blocking())
collision_mask = STAGE_PHYSICS.prop_mask(is_actor_blocking())
RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED:
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
freeze = false
collision_layer = STAGE_PHYSICS.prop_layer(is_actor_blocking())
collision_mask = STAGE_PHYSICS.prop_mask(is_actor_blocking())
RuntimeRole.HELD:
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
freeze = true
collision_layer = 0
collision_mask = 0
if role != RuntimeRole.HELD:
apply_stage_depth()
# A frozen RigidBody2D can push its cached EDIT transform back onto the
# node during the next physics sync. When PLAY scale has been moved into the
# child geometry, keep the physics server's body transform at unit scale too
# so that a START_STILL prop is not scaled twice on its first physics tick.
if _play_scale_active and role != RuntimeRole.EDIT:
_sync_transform_to_physics_server()
physics_profile_changed.emit()
func has_usable_grip() -> bool:
var anchors: Dictionary = get_interaction_anchors()
return not PropAnchorDataScript.compatible_grip_ids(anchors, 0).is_empty() or not PropAnchorDataScript.compatible_grip_ids(anchors, 1).is_empty()
func can_be_picked_up() -> bool:
return bool(stage_roles.get("can_pick_up", false)) and has_usable_grip()
func is_actor_blocking() -> bool:
if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT:
return false
if runtime_role == RuntimeRole.RELEASED:
return bool(stage_roles.get("released_blocks_stickmen", true))
return bool(stage_roles.get("blocks_stickmen", false))
func can_receive_prop_contact() -> bool:
if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT:
return false
if runtime_role == RuntimeRole.RELEASED:
return bool(stage_roles.get("released_bumps_other_props", true))
return runtime_role == RuntimeRole.START_PHYSICS
func set_interaction_anchors(value: Dictionary) -> void:
interaction_anchors = PropAnchorDataScript.normalize_anchors(value)
@@ -328,6 +415,7 @@ func begin_play_instance_scale() -> void:
_play_scale_active = true
scale = Vector2.ONE
_apply_play_scale()
_sync_transform_to_physics_server()
func end_play_instance_scale() -> void:
if not _play_scale_active:
@@ -364,6 +452,13 @@ func _set_visual_scale(value: Vector2) -> void:
if visuals != null:
visuals.scale = value
func _sync_transform_to_physics_server() -> void:
var rid := get_rid()
if not rid.is_valid():
return
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, global_transform)
reset_physics_interpolation()
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
var result: PackedVector2Array = PackedVector2Array()
for point: Vector2 in points: