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") # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- enum Handle { NONE, TRANSLATE, ROTATE } # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- signal transform_committed(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 # --------------------------------------------------------------------------- # 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 _rotate_start_rotation: float = 0.0 var _rotate_start_angle: float = 0.0 var _rotate_center: Vector2 = Vector2.ZERO var _box_rect: Rect2 = Rect2() # --------------------------------------------------------------------------- # 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_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) 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() ## 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: _drag_anchor = node if not _targets.has(node): _targets = [node] _dragging = Handle.TRANSLATE _drag_last = world_pos func drag_to(world_pos: Vector2) -> void: if _dragging == Handle.NONE or _targets.is_empty(): return var delta := world_pos - _drag_last if _dragging == Handle.TRANSLATE: var actual := delta if snap_size > 0.0 and _drag_anchor != null and is_instance_valid(_drag_anchor): var old := _drag_anchor.global_position actual = _snap(old + delta) - old for node: Node2D in _targets: if is_instance_valid(node): node.global_position += 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 _drag_last = world_pos queue_redraw() func end_drag() -> void: if _dragging != Handle.NONE and not _targets.is_empty(): transform_committed.emit(_targets.duplicate()) _dragging = Handle.NONE _drag_anchor = null 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 _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 _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: return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)