feat: Enhance selection and grid functionality in sandbox stage and gizmos

This commit is contained in:
2026-08-29 00:18:57 -04:00
parent 2a6b4aa965
commit badff571f0
5 changed files with 128 additions and 55 deletions
+42 -8
View File
@@ -81,6 +81,7 @@ var _status_label: Label
var _grid_check: CheckBox
var _snap_check: CheckBox
var _grid_size_spin: SpinBox
var _grid_size_label: Label
var _grid_size: float = DEFAULT_GRID_SIZE
var _snap_enabled: bool = false
@@ -140,7 +141,13 @@ func _physics_process(_delta: float) -> void:
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event is InputEventMagnifyGesture:
# Touchpad pinch zoom.
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
elif event is InputEventPanGesture:
# Touchpad two-finger pan (delta is screen px; ×3 for speed parity).
_camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x
elif event is InputEventMouseButton:
_handle_mouse_button(event)
elif event is InputEventMouseMotion:
_handle_mouse_motion(event)
@@ -249,12 +256,16 @@ func _enter_edit_mode() -> void:
# so re-assert the authored transform on the next few physics frames.
_restore_frames_left = 3
_gizmos.set_enabled(true)
_apply_grid_settings()
_set_build_controls_visible(true)
func _enter_play_mode() -> void:
_gizmos.set_enabled(false)
_selection.clear_selection()
set_placement_mode("")
_apply_grid_settings()
_set_build_controls_visible(false)
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
(node as RigidBody2D).freeze = false
@@ -384,6 +395,9 @@ func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
_selection.toggle_selection(hit)
if _selection.is_selected(hit):
_gizmos.begin_translate_drag(hit, world_pos)
elif _selection.is_selected(hit):
# Already selected: keep the whole selection and drag it together.
_gizmos.begin_translate_drag(hit, world_pos)
else:
_selection.select_only(hit)
_gizmos.begin_translate_drag(hit, world_pos)
@@ -482,9 +496,9 @@ func _build_ui() -> void:
_snap_check.toggled.connect(_on_snap_toggled)
hbox.add_child(_snap_check)
var grid_label := Label.new()
grid_label.text = "Size"
hbox.add_child(grid_label)
_grid_size_label = Label.new()
_grid_size_label.text = "Size"
hbox.add_child(_grid_size_label)
_grid_size_spin = SpinBox.new()
_grid_size_spin.min_value = MIN_GRID_SIZE
@@ -555,8 +569,8 @@ func _on_grid_size_changed(value: float) -> void:
_save_settings()
func _on_selection_changed(nodes: Array) -> void:
_gizmos.set_target(_selection.get_primary())
func _on_selection_changed(nodes: Array[Node2D]) -> void:
_gizmos.set_targets(nodes)
if nodes.is_empty():
object_deselected.emit()
else:
@@ -568,8 +582,9 @@ func _on_hover_changed(node: Node2D) -> void:
_gizmos.set_hover(node)
func _on_transform_committed(node: Node2D) -> void:
_save_object_state(node)
func _on_transform_committed(nodes: Array[Node2D]) -> void:
for node: Node2D in nodes:
_save_object_state(node)
# ---------------------------------------------------------------------------
# Helpers
@@ -577,7 +592,12 @@ func _on_transform_committed(node: Node2D) -> void:
func _set_zoom(value: float) -> void:
var z := clampf(value, min_zoom, max_zoom)
var old_zoom := _camera.zoom.x
if is_equal_approx(z, old_zoom):
return
var mouse_world := _camera.get_global_mouse_position()
_camera.zoom = Vector2(z, z)
_camera.position = mouse_world - (mouse_world - _camera.position) * (old_zoom / z)
func _is_mouse_over_ui() -> bool:
@@ -594,10 +614,24 @@ func _apply_grid_settings() -> void:
if _grid != null:
_grid.grid_size = _grid_size
_grid.enabled = _show_grid
_grid.visible = _show_grid and current_mode == StageMode.EDIT
if _gizmos != null:
_gizmos.snap_size = _grid_size if _snap_enabled else 0.0
func _set_build_controls_visible(visible: bool) -> void:
for btn: Button in _palette_buttons.values():
btn.visible = visible
if _grid_check != null:
_grid_check.visible = visible
if _snap_check != null:
_snap_check.visible = visible
if _grid_size_spin != null:
_grid_size_spin.visible = visible
if _grid_size_label != null:
_grid_size_label.visible = visible
## Direct Node2D children of World, excluding the ragdoll body container.
func _world_children_selectable() -> Array[Node2D]:
var result: Array[Node2D] = []
+59 -34
View File
@@ -1,11 +1,12 @@
class_name StageGizmos
extends Node2D
## StageGizmos - Hover highlight, selection outline and a rotate gizmo for the
## StageGizmos - Hover highlight, selection outlines and a rotate ring 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 /
## 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")
@@ -20,7 +21,7 @@ enum Handle { NONE, TRANSLATE, ROTATE }
# Signals
# ---------------------------------------------------------------------------
signal transform_committed(node: Node2D)
signal transform_committed(nodes: Array[Node2D])
# ---------------------------------------------------------------------------
# Style constants (screen-space px; divided by zoom for world-space drawing)
@@ -47,18 +48,22 @@ var camera: Camera2D = null
var snap_size: float = 0.0
var _enabled: bool = true
var _target: Node2D = null
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_target(node: Node2D) -> void:
_target = node
func set_targets(nodes: Array[Node2D]) -> void:
_targets = nodes.duplicate()
queue_redraw()
@@ -73,20 +78,19 @@ func set_enabled(enabled: bool) -> void:
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.
## Returns ROTATE if the cursor is on the rotate ring (single selection only).
func hit_test(world_pos: Vector2) -> Handle:
if _target == null or not is_instance_valid(_target) or not _enabled:
if not _enabled or _targets.size() != 1:
return Handle.NONE
var rect := STAGE_SELECTION.get_world_aabb(_target)
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
@@ -97,36 +101,53 @@ func hit_test(world_pos: Vector2) -> Handle:
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 on `node` (used when an object body is clicked).
## 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:
_target = node
_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 _target == null or not is_instance_valid(_target) or _dragging == Handle.NONE:
if _dragging == Handle.NONE or _targets.is_empty():
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)
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:
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
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 _target != null and is_instance_valid(_target):
transform_committed.emit(_target)
if _dragging != Handle.NONE and not _targets.is_empty():
transform_committed.emit(_targets.duplicate())
_dragging = Handle.NONE
_drag_anchor = null
func is_dragging() -> bool:
@@ -137,7 +158,7 @@ func is_dragging() -> bool:
# ---------------------------------------------------------------------------
func _process(_delta: float) -> void:
if _enabled and (_target != null or _hovered != null or _box_rect.has_area()):
if _enabled and (_targets.size() > 0 or _hovered != null or _box_rect.size != Vector2.ZERO):
queue_redraw()
@@ -145,14 +166,18 @@ func _draw() -> void:
if not _enabled:
return
var zoom := _zoom()
if _hovered != null and is_instance_valid(_hovered) and _hovered != _target:
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)
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)
# 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.has_area():
draw_rect(_box_rect, HOVER_COLOR, false, HOVER_WIDTH / zoom)
if _box_rect.size != Vector2.ZERO:
draw_rect(_box_rect.abs(), HOVER_COLOR, false, HOVER_WIDTH / zoom)
# ---------------------------------------------------------------------------
# Internal helpers
+1 -1
View File
@@ -56,7 +56,7 @@ func get_primary() -> Node2D:
func clear_selection() -> void:
_selected.clear()
_primary = null
selection_changed.emit([])
selection_changed.emit(_selected.duplicate())
func select_only(node: Node2D) -> void: