Files
stickman/scripts/props/prop_block.gd
T
ryan 1d4a71525a 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.
2026-10-09 23:11:34 -04:00

526 lines
18 KiB
GDScript

@tool
class_name PropBlock
extends RigidBody2D
## PropBlock - Reusable dynamic vector prop (RigidBody2D).
##
## A root RigidBody2D with three children built in code — a Polygon2D (interior
## fill), a Line2D (crisp vector outline, round joints/caps), and a collision
## node: a CollisionPolygon2D with BUILD_SOLIDS for polygon props, or a
## CollisionShape2D with a CircleShape2D for circle props. Fully @tool: exported
## properties update the children live. A material preset selector sets mass,
## friction/bounce (PhysicsMaterial), and themed visuals.
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
# ---------------------------------------------------------------------------
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 = ""
## Stable identity of this placed prop in one Sandbox Stage scene. Unlike
## spawn_id, this value is unique per instance and survives scene save/load.
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
# ---------------------------------------------------------------------------
const POLYGON_NODE_NAME := "Polygon2D"
const OUTLINE_NODE_NAME := "Outline"
const COLLISION_POLYGON_NODE_NAME := "CollisionPolygon2D"
const COLLISION_SHAPE_NODE_NAME := "CollisionShape2D"
## Segment count for the generated circle polygon/outline.
const CIRCLE_SEGMENTS: int = 48
const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0)
const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
const MAX_CONTACTS_REPORTED := 32
# ---------------------------------------------------------------------------
# Exported properties
# ---------------------------------------------------------------------------
@export_enum("Polygon", "Circle") var shape_type: int = ShapeType.POLYGON:
set(value):
shape_type = value
_apply_shape()
@export var polygon_points: PackedVector2Array = PackedVector2Array():
set(value):
polygon_points = value
if shape_type == ShapeType.POLYGON:
_apply_polygon_geometry()
@export var radius: float = 32.0:
set(value):
radius = value
if shape_type == ShapeType.CIRCLE:
_apply_circle_geometry()
@export var fill_color: Color = DEFAULT_FILL_COLOR:
set(value):
fill_color = value
if _polygon != null:
_polygon.color = value
@export var outline_color: Color = DEFAULT_OUTLINE_COLOR:
set(value):
outline_color = value
if _outline != null:
_outline.default_color = value
@export var outline_width: float = DEFAULT_OUTLINE_WIDTH:
set(value):
outline_width = value
if _outline != null:
_outline.width = value
@export_enum("None", "Wood", "Rubber", "Cardboard", "Metal") var material_preset: int = MaterialPreset.NONE:
set(value):
material_preset = value
_apply_material_preset()
# ---------------------------------------------------------------------------
# Internal node references (built in _ready, @tool-safe)
# ---------------------------------------------------------------------------
var _polygon: Polygon2D
var _outline: Line2D
var _collision_polygon: CollisionPolygon2D
var _collision_shape: CollisionShape2D
var _circle_shape: CircleShape2D
var _custom_shapes: bool = false
var _visual_local_bounds := Rect2()
var _custom_collision_hull: PackedVector2Array = PackedVector2Array()
var _play_instance_scale: Vector2 = Vector2.ONE
var _play_scale_active: bool = false
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when this prop's body makes contact with another physics body.
## `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
# ---------------------------------------------------------------------------
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
continuous_cd = RigidBody2D.CCD_MODE_CAST_SHAPE
if not body_entered.is_connected(_on_body_entered):
body_entered.connect(_on_body_entered)
if not body_exited.is_connected(_on_body_exited):
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)
func get_interaction_anchors() -> Dictionary:
return PropAnchorDataScript.normalize_anchors(interaction_anchors, false)
func ensure_default_grip() -> void:
var normalized := get_interaction_anchors()
if not PropAnchorDataScript.ids_for_role(normalized, "grip").is_empty():
interaction_anchors = normalized
return
var bounds := _visual_local_bounds
if bounds.size == Vector2.ZERO:
if shape_type == ShapeType.CIRCLE:
bounds = Rect2(Vector2(-radius, -radius), Vector2(radius * 2.0, radius * 2.0))
elif polygon_points.size() >= 3:
var min_point := polygon_points[0]
var max_point := polygon_points[0]
for point: Vector2 in polygon_points:
min_point = min_point.min(point)
max_point = max_point.max(point)
bounds = Rect2(min_point, max_point - min_point)
interaction_anchors = PropAnchorDataScript.preset_layout("one_handed", bounds)
func _on_body_entered(body: Node) -> void:
var center: Vector2 = (body as Node2D).global_position if body is Node2D else global_position
var normal: Vector2 = (center - global_position).normalized()
var position: Vector2 = global_position
var direct_state := PhysicsServer2D.body_get_direct_state(get_rid())
if direct_state != null:
for index: int in direct_state.get_contact_count():
if direct_state.get_contact_collider_id(index) == body.get_instance_id():
var reported: Vector2 = direct_state.get_contact_local_normal(index)
if not reported.is_zero_approx():
normal = reported.normalized()
if normal.dot(center - global_position) < 0.0:
normal = -normal
position = direct_state.get_contact_local_position(index)
break
collided.emit(body, normal, position)
func _on_body_exited(body: Node) -> void:
separated.emit(body)
# ---------------------------------------------------------------------------
# Internal build / apply
# ---------------------------------------------------------------------------
func _ensure_children() -> void:
_polygon = get_node_or_null(NodePath(POLYGON_NODE_NAME)) as Polygon2D
if _polygon == null:
_polygon = Polygon2D.new()
_polygon.name = POLYGON_NODE_NAME
add_child(_polygon)
_outline = get_node_or_null(NodePath(OUTLINE_NODE_NAME)) as Line2D
if _outline == null:
_outline = Line2D.new()
_outline.name = OUTLINE_NODE_NAME
_outline.joint_mode = Line2D.LINE_JOINT_ROUND
_outline.begin_cap_mode = Line2D.LINE_CAP_ROUND
_outline.end_cap_mode = Line2D.LINE_CAP_ROUND
add_child(_outline)
_collision_polygon = get_node_or_null(NodePath(COLLISION_POLYGON_NODE_NAME)) as CollisionPolygon2D
if _collision_polygon == null:
_collision_polygon = CollisionPolygon2D.new()
_collision_polygon.name = COLLISION_POLYGON_NODE_NAME
_collision_polygon.build_mode = CollisionPolygon2D.BUILD_SOLIDS
add_child(_collision_polygon)
_collision_shape = get_node_or_null(NodePath(COLLISION_SHAPE_NODE_NAME)) as CollisionShape2D
if _collision_shape == null:
_collision_shape = CollisionShape2D.new()
_collision_shape.name = COLLISION_SHAPE_NODE_NAME
_circle_shape = CircleShape2D.new()
_circle_shape.radius = radius
_collision_shape.shape = _circle_shape
add_child(_collision_shape)
else:
# A pre-existing collision shape (e.g. persisted in a scene) may already
# hold a circle shape; reuse it so radius updates keep working.
_circle_shape = _collision_shape.shape as CircleShape2D
func _apply_shape() -> void:
if shape_type == ShapeType.CIRCLE:
_apply_circle_geometry()
_set_collision_polygon_enabled(false)
_set_collision_shape_enabled(true)
else:
_apply_polygon_geometry()
_set_collision_polygon_enabled(true)
_set_collision_shape_enabled(false)
func _apply_polygon_geometry() -> void:
if _polygon != null:
_polygon.polygon = polygon_points
if _collision_polygon != null:
_collision_polygon.polygon = polygon_points if polygon_points.size() >= 3 else PackedVector2Array()
if _outline != null:
var outline_points := polygon_points.duplicate()
if not outline_points.is_empty():
outline_points.append(polygon_points[0])
_outline.points = outline_points
func _apply_circle_geometry() -> void:
var loop := PackedVector2Array()
for i: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(i) / float(CIRCLE_SEGMENTS)
loop.append(Vector2(cos(angle), sin(angle)) * radius)
if _polygon != null:
_polygon.polygon = loop
if _outline != null:
var outline_points := loop.duplicate()
if not outline_points.is_empty():
outline_points.append(loop[0])
_outline.points = outline_points
if _circle_shape != null:
_circle_shape.radius = radius
func _set_collision_polygon_enabled(enabled: bool) -> void:
if _collision_polygon != null:
_collision_polygon.disabled = not enabled
func _set_collision_shape_enabled(enabled: bool) -> void:
if _collision_shape != null:
_collision_shape.disabled = not enabled
func _apply_style() -> void:
if _polygon != null:
_polygon.color = fill_color
if _outline != null:
_outline.default_color = outline_color
_outline.width = outline_width
func _apply_material_preset() -> void:
mass = mass_for(material_preset)
physics_material_override = physics_material(material_preset)
if not _custom_shapes and material_preset != MaterialPreset.NONE:
fill_color = tint_for(material_preset)
outline_color = tint_for(material_preset).darkened(0.55)
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void:
_ensure_children()
_custom_shapes = true
_polygon.visible = false
_outline.visible = false
_set_collision_shape_enabled(false)
_set_collision_polygon_enabled(true)
_custom_collision_hull = collision_hull.duplicate()
_collision_polygon.polygon = _custom_collision_hull
var visuals := get_node_or_null(NodePath("Visuals")) as Node2D
if visuals == null:
visuals = Node2D.new(); visuals.name = "Visuals"; add_child(visuals)
for child: Node in visuals.get_children(): child.queue_free()
_visual_local_bounds = PropVisuals.build(visuals, shapes)
_apply_material_preset()
func has_custom_shapes() -> bool:
return _custom_shapes
func get_visual_local_bounds() -> Rect2:
return _visual_local_bounds
func get_interaction_scale() -> Vector2:
return _play_instance_scale if _play_scale_active else scale.abs()
## RigidBody2D transforms do not reliably retain scale during simulation. Move
## the authored uniform scale into child visuals and collision geometry for
## PLAY, then restore the root scale on return to EDIT for scene persistence.
func begin_play_instance_scale() -> void:
if _play_scale_active:
return
_play_instance_scale = scale
if _play_instance_scale.is_equal_approx(Vector2.ONE):
return
_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:
return
_play_scale_active = false
_set_visual_scale(Vector2.ONE)
_apply_shape()
if _custom_shapes and _collision_polygon != null:
_collision_polygon.polygon = _custom_collision_hull
scale = _play_instance_scale
func _apply_play_scale() -> void:
_set_visual_scale(_play_instance_scale)
if _custom_shapes:
if _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(_custom_collision_hull, _play_instance_scale)
return
if shape_type == ShapeType.CIRCLE:
# A non-uniform legacy instance scale makes an ellipse. Use the generated
# polygon hull while simulating so collision follows both axes exactly.
if _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(_circle_loop(), _play_instance_scale)
_set_collision_shape_enabled(false)
_set_collision_polygon_enabled(true)
elif _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(polygon_points, _play_instance_scale)
func _set_visual_scale(value: Vector2) -> void:
if _polygon != null:
_polygon.scale = value
if _outline != null:
_outline.scale = value
var visuals: Node2D = get_node_or_null(NodePath("Visuals")) as Node2D
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:
result.append(point * value)
return result
func _circle_loop() -> PackedVector2Array:
var result: PackedVector2Array = PackedVector2Array()
for index: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
result.append(Vector2(cos(angle), sin(angle)) * radius)
return result
# ---------------------------------------------------------------------------
# Static preset factories
# ---------------------------------------------------------------------------
static func physics_material(preset: int) -> PhysicsMaterial:
var mat := PhysicsMaterial.new()
match preset:
MaterialPreset.WOOD:
mat.friction = 0.6
mat.bounce = 0.1
MaterialPreset.RUBBER:
mat.friction = 0.9
mat.bounce = 0.85
MaterialPreset.CARDBOARD:
mat.friction = 0.3
mat.bounce = 0.05
MaterialPreset.METAL:
mat.friction = 0.9
mat.bounce = 0.0
_:
mat.friction = 0.5
mat.bounce = 0.05
return mat
static func mass_for(preset: int) -> float:
match preset:
MaterialPreset.WOOD:
return 3.0
MaterialPreset.RUBBER:
return 0.5
MaterialPreset.CARDBOARD:
return 0.4
MaterialPreset.METAL:
return 8.0
_:
return 1.0
static func tint_for(preset: int) -> Color:
match preset:
MaterialPreset.WOOD:
return Color(0.55, 0.38, 0.2, 1.0)
MaterialPreset.RUBBER:
return Color(0.9, 0.2, 0.2, 1.0)
MaterialPreset.CARDBOARD:
return Color(0.85, 0.72, 0.45, 1.0)
MaterialPreset.METAL:
return Color(0.5, 0.55, 0.6, 1.0)
_:
return DEFAULT_FILL_COLOR