- 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.
273 lines
10 KiB
GDScript
273 lines
10 KiB
GDScript
class_name StageGizmos
|
|
extends Node2D
|
|
## StageGizmos - Hover highlight, selection outlines and a rotate ring for the
|
|
## Sandbox Stage Builder (Phase 2).
|
|
##
|
|
## Translation is done by dragging an object directly (no move handle); a drag
|
|
## moves the whole multi-selection. The rotate ring only appears when exactly one
|
|
## object is selected (rotating a multi-selection isn't useful). Pure drawing +
|
|
## distance-based hit-testing (no Area2D). Dragging drives global_position /
|
|
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
|
|
|
|
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
|
|
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enums
|
|
# ---------------------------------------------------------------------------
|
|
|
|
enum Handle { NONE, TRANSLATE, ROTATE, SCALE }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signals
|
|
# ---------------------------------------------------------------------------
|
|
|
|
signal transform_committed(nodes: Array[Node2D])
|
|
signal transform_started(nodes: Array[Node2D])
|
|
signal transform_updated(nodes: Array[Node2D])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Style constants (screen-space px; divided by zoom for world-space drawing)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
const SELECTION_COLOR := Color(1.0, 1.0, 1.0, 0.9)
|
|
const HOVER_COLOR := Color(1.0, 0.9, 0.2, 0.5)
|
|
const ROTATE_COLOR := Color(0.3, 0.6, 1.0, 0.9)
|
|
|
|
const SELECTION_WIDTH := 2.0
|
|
const HOVER_WIDTH := 1.5
|
|
const ROTATE_WIDTH := 2.0
|
|
|
|
const ROTATE_HANDLE_MARGIN_PX := 48.0
|
|
const ROTATE_RING_TOLERANCE_PX := 12.0
|
|
const SCALE_CROSS_SIZE_PX := 5.0
|
|
const SCALE_HIT_RADIUS_PX := 12.0
|
|
const MIN_SCALE: float = 0.05
|
|
const MAX_SCALE: float = 20.0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# State
|
|
# ---------------------------------------------------------------------------
|
|
|
|
var camera: Camera2D = null
|
|
|
|
## When > 0, translation drags snap to this grid size (0 = off).
|
|
var snap_size: float = 0.0
|
|
|
|
var _enabled: bool = true
|
|
var _targets: Array[Node2D] = []
|
|
var _hovered: Node2D = null
|
|
var _dragging: Handle = Handle.NONE
|
|
var _drag_last: Vector2 = Vector2.ZERO
|
|
var _drag_anchor: Node2D = null
|
|
var _translate_drag_origin: Vector2 = Vector2.ZERO
|
|
var _translate_start_positions: Dictionary = {}
|
|
var _rotate_start_rotation: float = 0.0
|
|
var _rotate_start_angle: float = 0.0
|
|
var _rotate_center: Vector2 = Vector2.ZERO
|
|
var _box_rect: Rect2 = Rect2()
|
|
var _scale_start: Vector2 = Vector2.ONE
|
|
var _scale_pivot: Vector2 = Vector2.ZERO
|
|
var _scale_corner: Vector2 = Vector2.ZERO
|
|
var _scale_changed: bool = false
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func set_targets(nodes: Array[Node2D]) -> void:
|
|
_targets = nodes.duplicate()
|
|
queue_redraw()
|
|
|
|
|
|
func set_hover(node: Node2D) -> void:
|
|
_hovered = node
|
|
queue_redraw()
|
|
|
|
|
|
func set_enabled(enabled: bool) -> void:
|
|
_enabled = enabled
|
|
visible = enabled
|
|
queue_redraw()
|
|
|
|
|
|
func set_snap_size(size: float) -> void:
|
|
snap_size = maxf(size, 0.0)
|
|
|
|
|
|
func set_box_rect(rect: Rect2) -> void:
|
|
_box_rect = rect
|
|
queue_redraw()
|
|
|
|
|
|
## Returns ROTATE if the cursor is on the rotate ring (single selection only).
|
|
func hit_test(world_pos: Vector2) -> Handle:
|
|
if not _enabled or _targets.size() != 1:
|
|
return Handle.NONE
|
|
var target := _targets[0]
|
|
if target == null or not is_instance_valid(target):
|
|
return Handle.NONE
|
|
var rect := STAGE_SELECTION.get_world_aabb(target)
|
|
if target is PropBlockScript:
|
|
for corner: Vector2 in _corners(rect):
|
|
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
|
|
return Handle.SCALE
|
|
var ring := _rotate_ring_radius(rect)
|
|
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
|
|
return Handle.ROTATE
|
|
return Handle.NONE
|
|
|
|
|
|
## Begins a rotate drag (used when the ring is clicked).
|
|
func begin_drag(world_pos: Vector2) -> void:
|
|
_dragging = hit_test(world_pos)
|
|
_drag_last = world_pos
|
|
if _dragging == Handle.ROTATE and _targets.size() == 1 and is_instance_valid(_targets[0]):
|
|
_rotate_center = STAGE_SELECTION.get_world_aabb(_targets[0]).get_center()
|
|
_rotate_start_rotation = _targets[0].global_rotation
|
|
_rotate_start_angle = (world_pos - _rotate_center).angle()
|
|
transform_started.emit(_targets.duplicate())
|
|
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
|
|
var target: Node2D = _targets[0]
|
|
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(target)
|
|
_scale_pivot = target.global_position
|
|
_scale_corner = _nearest_corner(rect, world_pos)
|
|
_scale_start = target.scale
|
|
_scale_changed = false
|
|
transform_started.emit(_targets.duplicate())
|
|
|
|
|
|
## Begins a translate drag anchored on `node` (used when an object body is
|
|
## clicked). The drag moves the whole current selection (`_targets`); if `node`
|
|
## is not currently selected, the drag targets just `node`.
|
|
func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void:
|
|
if node == null or not is_instance_valid(node):
|
|
return
|
|
_drag_anchor = node
|
|
if not _targets.has(node):
|
|
_targets = [node]
|
|
_dragging = Handle.TRANSLATE
|
|
_drag_last = world_pos
|
|
_translate_drag_origin = world_pos
|
|
_translate_start_positions.clear()
|
|
for target: Node2D in _targets:
|
|
if target != null and is_instance_valid(target):
|
|
_translate_start_positions[target] = target.global_position
|
|
transform_started.emit(_targets.duplicate())
|
|
|
|
|
|
func drag_to(world_pos: Vector2) -> void:
|
|
if _dragging == Handle.NONE or _targets.is_empty():
|
|
return
|
|
if _dragging == Handle.TRANSLATE:
|
|
var actual := world_pos - _translate_drag_origin
|
|
if snap_size > 0.0 and _drag_anchor != null and is_instance_valid(_drag_anchor):
|
|
var anchor_start: Vector2 = _translate_start_positions.get(_drag_anchor, _drag_anchor.global_position)
|
|
actual = _snap(anchor_start + actual) - anchor_start
|
|
for node: Node2D in _targets:
|
|
if is_instance_valid(node) and _translate_start_positions.has(node):
|
|
node.global_position = (_translate_start_positions[node] as Vector2) + actual
|
|
elif _dragging == Handle.ROTATE:
|
|
if _targets.size() == 1 and is_instance_valid(_targets[0]):
|
|
var target := _targets[0]
|
|
var current_angle := (world_pos - _rotate_center).angle()
|
|
var angle_delta := current_angle - _rotate_start_angle
|
|
var new_rotation := _rotate_start_rotation + angle_delta
|
|
if Input.is_key_pressed(KEY_CTRL):
|
|
new_rotation = snappedf(new_rotation, deg_to_rad(15.0))
|
|
target.global_rotation = new_rotation
|
|
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
|
|
var prop: Node2D = _targets[0]
|
|
var start: Vector2 = _scale_corner - _scale_pivot
|
|
var denominator: float = start.length_squared()
|
|
if denominator > 0.0001:
|
|
var requested: float = (world_pos - _scale_pivot).dot(start) / denominator
|
|
if is_finite(requested):
|
|
var lower: float = maxf(MIN_SCALE / maxf(absf(_scale_start.x), 0.0001), MIN_SCALE / maxf(absf(_scale_start.y), 0.0001))
|
|
var upper: float = minf(MAX_SCALE / maxf(absf(_scale_start.x), 0.0001), MAX_SCALE / maxf(absf(_scale_start.y), 0.0001))
|
|
var next_scale: Vector2 = _scale_start * clampf(requested, lower, upper)
|
|
if not next_scale.is_equal_approx(prop.scale):
|
|
prop.scale = next_scale
|
|
_scale_changed = true
|
|
_drag_last = world_pos
|
|
transform_updated.emit(_targets.duplicate())
|
|
queue_redraw()
|
|
|
|
|
|
func end_drag() -> void:
|
|
if _dragging != Handle.NONE and not _targets.is_empty() and (_dragging != Handle.SCALE or _scale_changed):
|
|
transform_committed.emit(_targets.duplicate())
|
|
_dragging = Handle.NONE
|
|
_drag_anchor = null
|
|
_scale_changed = false
|
|
_translate_start_positions.clear()
|
|
|
|
|
|
func is_dragging() -> bool:
|
|
return _dragging != Handle.NONE
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frame / drawing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _process(_delta: float) -> void:
|
|
if _enabled and (_targets.size() > 0 or _hovered != null or _box_rect.size != Vector2.ZERO):
|
|
queue_redraw()
|
|
|
|
|
|
func _draw() -> void:
|
|
if not _enabled:
|
|
return
|
|
var zoom := _zoom()
|
|
if _hovered != null and is_instance_valid(_hovered) and not _hovered.is_queued_for_deletion() and not _targets.has(_hovered):
|
|
draw_rect(STAGE_SELECTION.get_world_aabb(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
|
|
# A selection outline for every selected object.
|
|
for node: Node2D in _targets:
|
|
if node != null and is_instance_valid(node):
|
|
draw_rect(STAGE_SELECTION.get_world_aabb(node), SELECTION_COLOR, false, SELECTION_WIDTH / zoom)
|
|
# Rotate ring only for a single selection.
|
|
if _targets.size() == 1 and is_instance_valid(_targets[0]):
|
|
var rect := STAGE_SELECTION.get_world_aabb(_targets[0])
|
|
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
|
|
if _targets[0] is PropBlockScript:
|
|
var half: float = SCALE_CROSS_SIZE_PX / zoom
|
|
for corner: Vector2 in _corners(rect):
|
|
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
|
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
|
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_COLOR, 1.5 / zoom)
|
|
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_COLOR, 1.5 / zoom)
|
|
if _box_rect.size != Vector2.ZERO:
|
|
draw_rect(_box_rect.abs(), HOVER_COLOR, false, HOVER_WIDTH / zoom)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
func _zoom() -> float:
|
|
if camera != null and is_instance_valid(camera):
|
|
return maxf(camera.zoom.x, 0.0001)
|
|
return 1.0
|
|
|
|
|
|
func _rotate_ring_radius(rect: Rect2) -> float:
|
|
return rect.size.length() * 0.5 + ROTATE_HANDLE_MARGIN_PX / _zoom()
|
|
|
|
|
|
func _snap(v: Vector2) -> Vector2:
|
|
if snap_size <= 0.0:
|
|
return v
|
|
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
|
|
|
|
func _corners(rect: Rect2) -> Array[Vector2]:
|
|
return [rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]
|
|
|
|
func _nearest_corner(rect: Rect2, world_pos: Vector2) -> Vector2:
|
|
var nearest: Vector2 = rect.position
|
|
var distance: float = INF
|
|
for corner: Vector2 in _corners(rect):
|
|
var candidate: float = corner.distance_squared_to(world_pos)
|
|
if candidate < distance:
|
|
distance = candidate
|
|
nearest = corner
|
|
return nearest
|