Files
ryan 1f91f3d2e5 Add headless regression tests for Phase 4b features
- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions.
- Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates.
- Add terrain drag-painting tests to verify correct block placement behavior and conflict handling.
- Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
2026-09-04 15:08:08 -04:00

198 lines
6.9 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")
# ---------------------------------------------------------------------------
# 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 _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 _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)