class_name StageGizmos extends Node2D ## StageGizmos - Hover highlight, selection outline and a rotate gizmo for the ## Sandbox Stage Builder (Phase 2). ## ## Translation is done by dragging the object directly (no translate handle); ## only the rotate ring is a dedicated handle. Pure drawing + distance-based ## handle 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(node: 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 _target: Node2D = null var _hovered: Node2D = null var _dragging: Handle = Handle.NONE var _drag_last: Vector2 = Vector2.ZERO var _box_rect: Rect2 = Rect2() # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- func set_target(node: Node2D) -> void: _target = node 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 get_target() -> Node2D: return _target func set_box_rect(rect: Rect2) -> void: _box_rect = rect queue_redraw() ## Returns ROTATE if the cursor is on the rotate ring of the primary selection. func hit_test(world_pos: Vector2) -> Handle: if _target == null or not is_instance_valid(_target) or not _enabled: 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 ## Begins a translate drag on `node` (used when an object body is clicked). func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void: _target = node _dragging = Handle.TRANSLATE _drag_last = world_pos func drag_to(world_pos: Vector2) -> void: if _target == null or not is_instance_valid(_target) or _dragging == Handle.NONE: return var delta := world_pos - _drag_last if _dragging == Handle.TRANSLATE: _target.global_position += delta if snap_size > 0.0: _target.global_position = _snap(_target.global_position) elif _dragging == Handle.ROTATE: var center := STAGE_SELECTION.get_world_aabb(_target).get_center() var before := (_drag_last - center).angle() var after := (world_pos - center).angle() _target.global_rotation += after - before _drag_last = world_pos queue_redraw() func end_drag() -> void: if _dragging != Handle.NONE and _target != null and is_instance_valid(_target): transform_committed.emit(_target) _dragging = Handle.NONE func is_dragging() -> bool: return _dragging != Handle.NONE # --------------------------------------------------------------------------- # Frame / drawing # --------------------------------------------------------------------------- func _process(_delta: float) -> void: if _enabled and (_target != null or _hovered != null or _box_rect.has_area()): queue_redraw() func _draw() -> void: if not _enabled: return var zoom := _zoom() if _hovered != null and is_instance_valid(_hovered) and _hovered != _target: draw_rect(STAGE_SELECTION.get_world_aabb(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom) if _target != null and is_instance_valid(_target): var rect := STAGE_SELECTION.get_world_aabb(_target) draw_rect(rect, SELECTION_COLOR, false, SELECTION_WIDTH / zoom) draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true) if _box_rect.has_area(): draw_rect(_box_rect, 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)