936 lines
34 KiB
GDScript
936 lines
34 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 PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.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 stage_name: String = ""
|
|
var interaction_anchors: Dictionary = {}
|
|
var interaction_reserved_by: int = 0
|
|
var interaction_owner_id: int = 0
|
|
var use_spots: Array[Dictionary] = []
|
|
var next_use_spot_number: int = 1
|
|
var stage_roles: Dictionary = {}
|
|
var runtime_role: RuntimeRole = RuntimeRole.EDIT
|
|
var wobble_on_impact: bool = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
const DEFAULT_WOBBLE_DURATION: float = 0.48
|
|
const DEFAULT_WOBBLE_ANGLE_DEGREES: float = 7.0
|
|
const DEFAULT_WOBBLE_OFFSET_LOCAL: float = 4.0
|
|
const OWNER_COLLISION_GRACE_CLEAR_MARGIN: float = 24.0
|
|
const OWNER_COLLISION_GRACE_CLEAR_FRAMES: int = 2
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|
|
var _wobble_duration: float = DEFAULT_WOBBLE_DURATION
|
|
var _wobble_angle_degrees: float = DEFAULT_WOBBLE_ANGLE_DEGREES
|
|
var _wobble_offset_local: float = DEFAULT_WOBBLE_OFFSET_LOCAL
|
|
var _wobble_elapsed: float = 0.0
|
|
var _wobble_active: bool = false
|
|
var _wobble_paused: bool = false
|
|
var _wobble_center: Vector2 = Vector2.ZERO
|
|
var _wobble_baselines: Dictionary = {}
|
|
var _last_physics_velocity: Vector2 = Vector2.ZERO
|
|
var _has_physics_velocity_sample: bool = false
|
|
var _owner_collision_grace_actor_id: int = 0
|
|
var _owner_collision_grace_actor_ref: WeakRef
|
|
var _owner_collision_grace_locomotion_ref: WeakRef
|
|
var _owner_collision_grace_body_refs: Array[WeakRef] = []
|
|
var _owner_collision_grace_role: RuntimeRole = RuntimeRole.EDIT
|
|
var _owner_collision_grace_while_reserved: bool = false
|
|
var _owner_collision_grace_clear_frames: int = 0
|
|
var _owner_collision_grace_started_frame: int = -1
|
|
var _owner_collision_grace_last_sample_frame: int = -1
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
reset_impact_velocity_baseline()
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if Engine.is_editor_hint() or not _wobble_active or _wobble_paused:
|
|
return
|
|
_wobble_elapsed = minf(_wobble_elapsed + maxf(delta, 0.0), _wobble_duration)
|
|
var progress: float = clampf(_wobble_elapsed / _wobble_duration, 0.0, 1.0)
|
|
if progress >= 1.0:
|
|
cancel_wobble()
|
|
return
|
|
var envelope: float = (1.0 - progress) * (1.0 - progress)
|
|
var phase: float = TAU * 2.5 * progress
|
|
var angle: float = deg_to_rad(_wobble_angle_degrees) * sin(phase) * envelope
|
|
var offset: float = _wobble_offset_local * cos(phase) * envelope
|
|
_apply_wobble_pose(angle, offset)
|
|
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
_update_owner_collision_grace()
|
|
if not freeze and runtime_role in [RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED]:
|
|
_last_physics_velocity = linear_velocity
|
|
_has_physics_velocity_sample = _last_physics_velocity.is_finite()
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
clear_owner_collision_grace()
|
|
cancel_wobble()
|
|
|
|
|
|
func set_wobble_on_impact(enabled: bool) -> void:
|
|
wobble_on_impact = enabled
|
|
if not enabled:
|
|
cancel_wobble()
|
|
|
|
|
|
func set_wobble_style(duration: float, angle_degrees: float, offset_local: float) -> void:
|
|
if not is_finite(duration) or not is_finite(angle_degrees) or not is_finite(offset_local):
|
|
return
|
|
_wobble_duration = maxf(0.05, duration)
|
|
_wobble_angle_degrees = maxf(0.0, angle_degrees)
|
|
_wobble_offset_local = maxf(0.0, offset_local)
|
|
|
|
|
|
func preview_wobble() -> void:
|
|
_start_wobble()
|
|
|
|
|
|
func play_impact_wobble() -> void:
|
|
if wobble_on_impact:
|
|
_start_wobble()
|
|
|
|
|
|
func cancel_wobble() -> void:
|
|
for raw_node: Variant in _wobble_baselines.keys():
|
|
var node: Node2D = raw_node as Node2D
|
|
if node == null or not is_instance_valid(node):
|
|
continue
|
|
var baseline: Dictionary = _wobble_baselines[raw_node]
|
|
node.position = baseline["position"] as Vector2
|
|
node.rotation = float(baseline["rotation"])
|
|
node.scale = baseline["scale"] as Vector2
|
|
_wobble_baselines.clear()
|
|
_wobble_active = false
|
|
_wobble_paused = false
|
|
_wobble_elapsed = 0.0
|
|
|
|
|
|
func set_wobble_paused(paused: bool) -> void:
|
|
_wobble_paused = paused and _wobble_active
|
|
|
|
|
|
func get_impact_velocity() -> Vector2:
|
|
if runtime_role in [RuntimeRole.EDIT, RuntimeRole.START_STILL, RuntimeRole.HELD] or freeze:
|
|
return Vector2.ZERO
|
|
if _has_physics_velocity_sample and _last_physics_velocity.is_finite():
|
|
return _last_physics_velocity
|
|
return linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
|
|
|
|
|
|
func reset_impact_velocity_baseline() -> void:
|
|
_last_physics_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO
|
|
_has_physics_velocity_sample = true
|
|
|
|
|
|
func _start_wobble() -> void:
|
|
cancel_wobble()
|
|
_capture_wobble_baselines()
|
|
if _wobble_baselines.is_empty():
|
|
return
|
|
_wobble_active = true
|
|
_wobble_elapsed = 0.0
|
|
_apply_wobble_pose(0.0, _wobble_offset_local)
|
|
|
|
|
|
func _capture_wobble_baselines() -> void:
|
|
var bounds: Dictionary = {"min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF}
|
|
for child_name: String in [POLYGON_NODE_NAME, OUTLINE_NODE_NAME, "Visuals"]:
|
|
var node: Node2D = get_node_or_null(NodePath(child_name)) as Node2D
|
|
if node == null or not node.visible:
|
|
continue
|
|
_wobble_baselines[node] = {"position": node.position, "rotation": node.rotation, "scale": node.scale}
|
|
_collect_artwork_bounds(node, bounds)
|
|
if float(bounds["min_x"]) <= float(bounds["max_x"]):
|
|
_wobble_center = Vector2(
|
|
(float(bounds["min_x"]) + float(bounds["max_x"])) * 0.5,
|
|
(float(bounds["min_y"]) + float(bounds["max_y"])) * 0.5
|
|
)
|
|
else:
|
|
_wobble_center = get_use_spot_local_bounds().get_center()
|
|
|
|
|
|
func _collect_artwork_bounds(node: Node, bounds: Dictionary) -> void:
|
|
if node is Polygon2D:
|
|
for point: Vector2 in (node as Polygon2D).polygon:
|
|
_accumulate_artwork_point(to_local((node as Polygon2D).to_global(point)), bounds)
|
|
elif node is Line2D:
|
|
for point: Vector2 in (node as Line2D).points:
|
|
_accumulate_artwork_point(to_local((node as Line2D).to_global(point)), bounds)
|
|
for child: Node in node.get_children():
|
|
_collect_artwork_bounds(child, bounds)
|
|
|
|
|
|
func _accumulate_artwork_point(point: Vector2, bounds: Dictionary) -> void:
|
|
bounds["min_x"] = minf(float(bounds["min_x"]), point.x)
|
|
bounds["min_y"] = minf(float(bounds["min_y"]), point.y)
|
|
bounds["max_x"] = maxf(float(bounds["max_x"]), point.x)
|
|
bounds["max_y"] = maxf(float(bounds["max_y"]), point.y)
|
|
|
|
|
|
func _apply_wobble_pose(angle: float, offset: float) -> void:
|
|
for raw_node: Variant in _wobble_baselines:
|
|
var node: Node2D = raw_node as Node2D
|
|
if node == null or not is_instance_valid(node):
|
|
continue
|
|
var baseline: Dictionary = _wobble_baselines[raw_node]
|
|
var base_position: Vector2 = baseline["position"] as Vector2
|
|
node.position = _wobble_center + (base_position - _wobble_center).rotated(angle) + Vector2(offset, 0.0)
|
|
node.rotation = float(baseline["rotation"]) + angle
|
|
node.scale = baseline["scale"] as Vector2
|
|
|
|
|
|
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
|
|
if _owner_collision_grace_actor_id != 0 and (role != _owner_collision_grace_role or not is_actor_blocking()):
|
|
clear_owner_collision_grace()
|
|
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))
|
|
|
|
|
|
## Temporarily ignores contacts with one stickman. Pickup reservations can keep
|
|
## protection active until the reservation ends; Set Down uses exit clearance.
|
|
## The prop's authored role and contacts with supports or other stickmen remain.
|
|
func begin_owner_collision_grace(actor_rig: Node2D, while_reserved: bool = false) -> void:
|
|
clear_owner_collision_grace()
|
|
if actor_rig == null or not is_instance_valid(actor_rig) or not is_actor_blocking():
|
|
return
|
|
_owner_collision_grace_actor_id = actor_rig.get_instance_id()
|
|
_owner_collision_grace_actor_ref = weakref(actor_rig)
|
|
_owner_collision_grace_role = runtime_role
|
|
_owner_collision_grace_while_reserved = while_reserved
|
|
_owner_collision_grace_clear_frames = 0
|
|
_owner_collision_grace_started_frame = Engine.get_physics_frames()
|
|
_owner_collision_grace_last_sample_frame = -1
|
|
for body: PhysicsBody2D in _actor_collision_bodies(actor_rig):
|
|
body.add_collision_exception_with(self)
|
|
add_collision_exception_with(body)
|
|
_owner_collision_grace_body_refs.append(weakref(body))
|
|
if actor_rig.has_method("get_locomotion"):
|
|
var locomotion: Node = actor_rig.call("get_locomotion") as Node
|
|
if locomotion != null and is_instance_valid(locomotion) and locomotion.has_method("register_owner_collision_grace_prop"):
|
|
locomotion.call("register_owner_collision_grace_prop", self)
|
|
_owner_collision_grace_locomotion_ref = weakref(locomotion)
|
|
|
|
|
|
func release_owner_collision_grace(actor_rig: Node2D) -> void:
|
|
if not is_owner_collision_grace_active_for(actor_rig):
|
|
return
|
|
_owner_collision_grace_while_reserved = false
|
|
_owner_collision_grace_clear_frames = 0
|
|
_owner_collision_grace_started_frame = Engine.get_physics_frames()
|
|
_owner_collision_grace_last_sample_frame = -1
|
|
|
|
|
|
func is_owner_collision_grace_active_for(actor: Node) -> bool:
|
|
return (
|
|
actor != null
|
|
and is_instance_valid(actor)
|
|
and _owner_collision_grace_actor_id != 0
|
|
and actor.get_instance_id() == _owner_collision_grace_actor_id
|
|
)
|
|
|
|
|
|
func clear_owner_collision_grace() -> void:
|
|
for body_ref: WeakRef in _owner_collision_grace_body_refs:
|
|
var body: PhysicsBody2D = body_ref.get_ref() as PhysicsBody2D
|
|
if body != null and is_instance_valid(body):
|
|
body.remove_collision_exception_with(self)
|
|
remove_collision_exception_with(body)
|
|
_owner_collision_grace_body_refs.clear()
|
|
var locomotion: Node = _owner_collision_grace_locomotion_ref.get_ref() as Node if _owner_collision_grace_locomotion_ref != null else null
|
|
if locomotion != null and is_instance_valid(locomotion) and locomotion.has_method("unregister_owner_collision_grace_prop"):
|
|
locomotion.call("unregister_owner_collision_grace_prop", self)
|
|
_owner_collision_grace_locomotion_ref = null
|
|
_owner_collision_grace_actor_ref = null
|
|
_owner_collision_grace_actor_id = 0
|
|
_owner_collision_grace_role = RuntimeRole.EDIT
|
|
_owner_collision_grace_while_reserved = false
|
|
_owner_collision_grace_clear_frames = 0
|
|
_owner_collision_grace_started_frame = -1
|
|
_owner_collision_grace_last_sample_frame = -1
|
|
|
|
|
|
func _update_owner_collision_grace() -> void:
|
|
if _owner_collision_grace_actor_id == 0:
|
|
return
|
|
var actor: Node2D = _owner_collision_grace_actor_ref.get_ref() as Node2D if _owner_collision_grace_actor_ref != null else null
|
|
if actor == null or not is_instance_valid(actor) or runtime_role != _owner_collision_grace_role or not is_actor_blocking():
|
|
clear_owner_collision_grace()
|
|
return
|
|
if _owner_collision_grace_while_reserved:
|
|
return
|
|
var physics_frame: int = Engine.get_physics_frames()
|
|
if physics_frame <= _owner_collision_grace_started_frame or physics_frame == _owner_collision_grace_last_sample_frame:
|
|
return
|
|
_owner_collision_grace_last_sample_frame = physics_frame
|
|
if _actor_collision_shapes_overlap(actor):
|
|
_owner_collision_grace_clear_frames = 0
|
|
return
|
|
_owner_collision_grace_clear_frames += 1
|
|
if _owner_collision_grace_clear_frames >= OWNER_COLLISION_GRACE_CLEAR_FRAMES:
|
|
clear_owner_collision_grace()
|
|
|
|
|
|
func _actor_collision_shapes_overlap(actor_rig: Node2D) -> bool:
|
|
if not is_inside_tree() or get_world_2d() == null or collision_layer == 0:
|
|
return false
|
|
var space_state: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state
|
|
if space_state == null:
|
|
return true
|
|
var active_shape_count: int = 0
|
|
for body: PhysicsBody2D in _actor_collision_bodies(actor_rig):
|
|
if body.collision_layer == 0:
|
|
continue
|
|
for child: Node in body.get_children():
|
|
var collision_shape: CollisionShape2D = child as CollisionShape2D
|
|
if collision_shape == null or collision_shape.disabled or collision_shape.shape == null:
|
|
continue
|
|
active_shape_count += 1
|
|
var query := PhysicsShapeQueryParameters2D.new()
|
|
query.shape = collision_shape.shape
|
|
query.transform = collision_shape.global_transform
|
|
query.collision_mask = collision_layer
|
|
query.margin = OWNER_COLLISION_GRACE_CLEAR_MARGIN
|
|
query.exclude = [body.get_rid()]
|
|
for hit: Dictionary in space_state.intersect_shape(query, 16):
|
|
if hit.get("collider") == self:
|
|
return true
|
|
return active_shape_count == 0
|
|
|
|
|
|
func _actor_collision_bodies(actor_rig: Node2D) -> Array[PhysicsBody2D]:
|
|
var result: Array[PhysicsBody2D] = []
|
|
var seen: Dictionary = {}
|
|
if actor_rig == null or not is_instance_valid(actor_rig):
|
|
return result
|
|
if actor_rig.has_method("get_collision_bodies"):
|
|
var collision_bodies: Variant = actor_rig.call("get_collision_bodies")
|
|
if collision_bodies is Dictionary:
|
|
for raw_body: Variant in (collision_bodies as Dictionary).values():
|
|
_append_actor_collision_body(raw_body, result, seen)
|
|
if actor_rig.has_method("get_ragdoll_bodies"):
|
|
var ragdoll_bodies: Variant = actor_rig.call("get_ragdoll_bodies")
|
|
if ragdoll_bodies is Array:
|
|
for raw_body: Variant in ragdoll_bodies as Array:
|
|
_append_actor_collision_body(raw_body, result, seen)
|
|
return result
|
|
|
|
|
|
func _append_actor_collision_body(raw_body: Variant, bodies: Array[PhysicsBody2D], seen: Dictionary) -> void:
|
|
var body: PhysicsBody2D = raw_body as PhysicsBody2D
|
|
if body == null or not is_instance_valid(body):
|
|
return
|
|
var body_id: int = body.get_instance_id()
|
|
if seen.has(body_id):
|
|
return
|
|
seen[body_id] = true
|
|
bodies.append(body)
|
|
|
|
|
|
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_use_spots() -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
for spot: Dictionary in use_spots:
|
|
result.append(spot.duplicate(true))
|
|
return result
|
|
|
|
|
|
func get_use_spot(spot_id: String) -> Dictionary:
|
|
return PROP_USE_SPOT_DATA.find(use_spots, spot_id)
|
|
|
|
|
|
func get_use_spot_local_bounds() -> Rect2:
|
|
if _custom_shapes:
|
|
return _visual_local_bounds
|
|
if shape_type == ShapeType.CIRCLE:
|
|
return Rect2(Vector2(-radius, -radius), Vector2(radius * 2.0, radius * 2.0))
|
|
if polygon_points.is_empty():
|
|
return Rect2()
|
|
var minimum: Vector2 = polygon_points[0]
|
|
var maximum: Vector2 = polygon_points[0]
|
|
for point: Vector2 in polygon_points:
|
|
minimum = minimum.min(point)
|
|
maximum = maximum.max(point)
|
|
return Rect2(minimum, maximum - minimum)
|
|
|
|
|
|
func add_use_spot(local_position: Vector2) -> String:
|
|
if not local_position.is_finite() or not get_use_spot_local_bounds().has_point(local_position):
|
|
return ""
|
|
var number: int = maxi(1, next_use_spot_number)
|
|
var spot_id: String = "spot_%d" % number
|
|
if not get_use_spot(spot_id).is_empty():
|
|
return ""
|
|
use_spots.append({"id": spot_id, "number": number, "name": PROP_USE_SPOT_DATA.fallback_name(number), "position": local_position})
|
|
next_use_spot_number = number + 1
|
|
return spot_id
|
|
|
|
|
|
func rename_use_spot(spot_id: String, spot_name: String) -> bool:
|
|
for index: int in use_spots.size():
|
|
if String(use_spots[index].get("id", "")) != spot_id:
|
|
continue
|
|
var number: int = int(use_spots[index].get("number", 1))
|
|
var normalized_name: String = spot_name.strip_edges()
|
|
use_spots[index]["name"] = normalized_name if not normalized_name.is_empty() else PROP_USE_SPOT_DATA.fallback_name(number)
|
|
return true
|
|
return false
|
|
|
|
|
|
func move_use_spot(spot_id: String, local_position: Vector2) -> bool:
|
|
if not local_position.is_finite() or not get_use_spot_local_bounds().has_point(local_position):
|
|
return false
|
|
for index: int in use_spots.size():
|
|
if String(use_spots[index].get("id", "")) == spot_id:
|
|
use_spots[index]["position"] = local_position
|
|
return true
|
|
return false
|
|
|
|
|
|
func erase_use_spot(spot_id: String) -> bool:
|
|
for index: int in use_spots.size():
|
|
if String(use_spots[index].get("id", "")) == spot_id:
|
|
use_spots.remove_at(index)
|
|
return true
|
|
return false
|
|
|
|
|
|
func use_spot_world_position(spot_id: String) -> Vector2:
|
|
var spot: Dictionary = get_use_spot(spot_id)
|
|
if spot.is_empty():
|
|
return Vector2.INF
|
|
var local_position: Vector2 = spot.get("position", Vector2.ZERO)
|
|
var baked_scale: Vector2 = _play_instance_scale if _play_scale_active else Vector2.ONE
|
|
return global_transform * (local_position * baked_scale)
|
|
|
|
|
|
func use_spot_local_from_world(world_position: Vector2) -> Vector2:
|
|
if not world_position.is_finite() or not _transform_is_invertible(global_transform):
|
|
return Vector2.INF
|
|
var local_position: Vector2 = to_local(world_position)
|
|
return local_position if get_use_spot_local_bounds().has_point(local_position) else Vector2.INF
|
|
|
|
|
|
func set_use_spots(spots: Array[Dictionary], next_number: int) -> void:
|
|
use_spots.clear()
|
|
for spot: Dictionary in spots:
|
|
use_spots.append(spot.duplicate(true))
|
|
var highest: int = 0
|
|
for spot: Dictionary in use_spots:
|
|
highest = maxi(highest, int(spot.get("number", 0)))
|
|
next_use_spot_number = maxi(highest + 1, next_number)
|
|
|
|
|
|
func _transform_is_invertible(value: Transform2D) -> bool:
|
|
return value.x.is_finite() and value.y.is_finite() and value.origin.is_finite() and is_finite(value.determinant()) and absf(value.determinant()) > 0.000001
|
|
|
|
|
|
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:
|
|
cancel_wobble()
|
|
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:
|
|
cancel_wobble()
|
|
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
|