Add sandbox stage builder scripts and functionality
- Introduced StageGizmos for hover highlighting, selection outlines, and rotation handles in the sandbox stage builder. - Added StageGrid for an optional world-space grid overlay that adjusts with camera panning and zooming. - Implemented StageSelection for geometric hit-testing and selection management of nodes in the sandbox. - Created StageSpawner as a registry-driven factory for spawning terrain, props, and stickmen, allowing for dynamic template management. - Each script includes necessary constants, state management, and public API methods for interaction.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user