- Added specifications for Phase 10a, introducing permanent size changes to `.prop` documents and reversible uniform instance scaling in Sandbox EDIT mode. - Updated `PropCanvas`, `PropAnchorData`, `PropEditor`, `StageGizmos`, `SandboxStage`, and `StageSelection` classes to support new scaling features. - Implemented document transactions for scaling, including normalization to a 96 px maximum dimension. - Introduced a new **Transform Whole Prop** tool in the Prop Editor for moving, rotating, and resizing props. - Created unit tests for the new scaling functionality, validating shape scaling, anchor transformations, and whole-prop interactions. - Ensured compatibility with existing prop formats and maintained the integrity of the document structure during scaling operations.
392 lines
13 KiB
GDScript
392 lines
13 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/prop_visuals.gd")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enums
|
|
# ---------------------------------------------------------------------------
|
|
|
|
enum ShapeType { POLYGON, CIRCLE }
|
|
|
|
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
|
|
|
## Stable library provenance used by Sandbox Stage scene files.
|
|
var spawn_id: String = ""
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _ready() -> void:
|
|
_ensure_children()
|
|
_apply_shape()
|
|
_apply_style()
|
|
# 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 _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
|
|
|
|
## 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()
|
|
|
|
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 _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
|