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:
@@ -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:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
class_name PropStageRoles
|
||||
extends RefCounted
|
||||
## Normalized, per-placement behavior for a prop in the Sandbox Stage.
|
||||
|
||||
const DEPTH_BEHIND: String = "behind"
|
||||
const DEPTH_FRONT: String = "front"
|
||||
const START_STILL: String = "still"
|
||||
const START_PHYSICS: String = "physics"
|
||||
|
||||
const ROLE_KEYS: Array[String] = [
|
||||
"depth", "start_motion", "holds_other_props", "blocks_stickmen",
|
||||
"can_pick_up", "released_blocks_stickmen", "released_bumps_other_props",
|
||||
"legacy_draw_order",
|
||||
]
|
||||
|
||||
static func new_placement(has_grip: bool = false) -> Dictionary:
|
||||
return {
|
||||
"depth": DEPTH_BEHIND,
|
||||
"start_motion": START_STILL,
|
||||
"holds_other_props": false,
|
||||
"blocks_stickmen": false,
|
||||
"can_pick_up": has_grip,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": false,
|
||||
}
|
||||
|
||||
|
||||
static func legacy_placement(has_grip: bool = true) -> Dictionary:
|
||||
return {
|
||||
"depth": DEPTH_BEHIND,
|
||||
"start_motion": START_PHYSICS,
|
||||
"holds_other_props": false,
|
||||
"blocks_stickmen": true,
|
||||
"can_pick_up": has_grip,
|
||||
"released_blocks_stickmen": true,
|
||||
"released_bumps_other_props": true,
|
||||
"legacy_draw_order": true,
|
||||
}
|
||||
|
||||
|
||||
static func normalize(raw: Variant, fallback: Dictionary = {}) -> Dictionary:
|
||||
var roles: Dictionary = new_placement(bool(fallback.get("can_pick_up", false)))
|
||||
if not fallback.is_empty():
|
||||
roles.merge(fallback, true)
|
||||
if raw is Dictionary:
|
||||
roles.merge((raw as Dictionary).duplicate(true), true)
|
||||
if String(roles.get("depth", DEPTH_BEHIND)) not in [DEPTH_BEHIND, DEPTH_FRONT]:
|
||||
roles["depth"] = DEPTH_BEHIND
|
||||
if String(roles.get("start_motion", START_STILL)) not in [START_STILL, START_PHYSICS]:
|
||||
roles["start_motion"] = START_STILL
|
||||
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props", "legacy_draw_order"]:
|
||||
roles[key] = bool(roles.get(key, false))
|
||||
return roles
|
||||
|
||||
|
||||
static func props_should_collide(a_roles: Dictionary, a_state: int, b_roles: Dictionary, b_state: int) -> bool:
|
||||
if a_state == 3 or b_state == 3 or a_state == 0 or b_state == 0:
|
||||
return false
|
||||
var a_still: bool = a_state == 1
|
||||
var b_still: bool = b_state == 1
|
||||
if a_still and b_still:
|
||||
return false
|
||||
if a_still:
|
||||
return bool(a_roles.get("holds_other_props", false)) and _accepts_prop_contact(b_roles, b_state)
|
||||
if b_still:
|
||||
return bool(b_roles.get("holds_other_props", false)) and _accepts_prop_contact(a_roles, a_state)
|
||||
return _accepts_prop_contact(a_roles, a_state) and _accepts_prop_contact(b_roles, b_state)
|
||||
|
||||
|
||||
static func _accepts_prop_contact(roles: Dictionary, state: int) -> bool:
|
||||
if state == 2:
|
||||
return true
|
||||
if state == 4:
|
||||
return bool(roles.get("released_bumps_other_props", true))
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1dsjejgn2r1v
|
||||
Reference in New Issue
Block a user