feat: Enhance selection and grid functionality in sandbox stage and gizmos
This commit is contained in:
@@ -553,9 +553,15 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
**Grid/snap:** an optional `StageGrid` overlay (drawn behind `World`) plus `Grid`/`Snap`
|
||||
`CheckBox` toggles and a `Size` `SpinBox`, persisted to `user://sandbox_settings.json`
|
||||
(`grid_size`/`snap_to_grid`/`show_grid`); snapping rounds the placement cursor and translate
|
||||
drags via `_snap_to_grid()` / `StageGizmos.snap_size`. UI built in code (CanvasLayer +
|
||||
drags via `_snap_to_grid()` / `StageGizmos.snap_size`. The grid is visible only in EDIT mode
|
||||
(`_apply_grid_settings()` sets `_grid.visible = _show_grid and current_mode == EDIT`, re-applied
|
||||
on mode change). **Touchpad:** `_input` handles `InputEventMagnifyGesture` (pinch zoom ×factor)
|
||||
and `InputEventPanGesture` (two-finger pan, `delta × 3 / zoom`) alongside the mouse wheel/middle
|
||||
pan. UI built in code (CanvasLayer +
|
||||
PanelContainer top bar): mode toggle, six palette buttons (built from the spawner registry),
|
||||
Grid/Snap/Size controls, status label.
|
||||
Grid/Snap/Size controls, status label. The build controls (spawn palette + Grid/Snap/Size) are
|
||||
hidden in PLAY via `_set_build_controls_visible()`, called from `_enter_edit_mode()` /
|
||||
`_enter_play_mode()` (the mode toggle and status label stay visible).
|
||||
- `scripts/stage_spawner.gd` — `class_name StageSpawner`, `extends RefCounted`; registry-driven
|
||||
spawner (Phase 2). A `_registry: Array[Dictionary]` maps ids to terrain/prop/stickman templates;
|
||||
adding a type = appending an entry (no hard-coded id `match`). Reuses `TerrainUtils.spawn_block`,
|
||||
@@ -577,13 +583,19 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`clear_selection`/`select_only`/`add_to_selection`/`toggle_selection`/`is_selected`/`hit_test`/
|
||||
`update_hover`/`box_select`.
|
||||
- `scripts/stage_gizmos.gd` — `class_name StageGizmos`, `extends Node2D`; hover highlight +
|
||||
selection outline + a rotate ring handle (Phase 2). **No translate handle** — objects are
|
||||
dragged directly by the root (`begin_translate_drag(node, world_pos)` sets the target + starts
|
||||
a TRANSLATE drag; `drag_to` drives `global_position` with optional grid snap via `snap_size`).
|
||||
Pure `_draw()` + distance-based hit-testing (no `Area2D`); `enum Handle { NONE, TRANSLATE,
|
||||
ROTATE }`. Rotation drives `global_rotation` about the AABB center. `transform_committed(node)`
|
||||
emitted on drag end. `set_enabled(false)` hides + disables in PLAY. Also draws the box-select
|
||||
marquee via `set_box_rect(rect)`.
|
||||
selection outlines + a rotate ring (Phase 2). **No translate handle** — objects are dragged
|
||||
directly by the root; `set_targets(nodes: Array[Node2D])` holds the current multi-selection,
|
||||
and `begin_translate_drag(node, world_pos)` starts a TRANSLATE drag that moves **all** selected
|
||||
nodes together (`drag_to` drives `global_position` with optional grid snap via `snap_size`,
|
||||
snapping the drag anchor and applying the same delta to the rest). The **rotate ring** is drawn
|
||||
and hit-tested only when exactly one node is selected (`hit_test` → `Handle.ROTATE`).
|
||||
The rotate drag is **absolute-from-start** — `drag_to()` sets `global_rotation` to
|
||||
`_rotate_start_rotation + (current − start angle)`, with holding **Ctrl** snapping to
|
||||
15° increments via `snappedf(new_rotation, deg_to_rad(15.0))`.
|
||||
`_draw()` draws a selection outline for **every** selected node. Pure `_draw()` +
|
||||
distance-based hit-testing (no `Area2D`); `enum Handle { NONE, TRANSLATE, ROTATE }`.
|
||||
`transform_committed(nodes: Array[Node2D])` emitted on drag end. `set_enabled(false)` hides +
|
||||
disables in PLAY. Also draws the box-select marquee via `set_box_rect(rect)`.
|
||||
- `scripts/stage_grid.gd` — `class_name StageGrid`, `extends Node2D`; the optional world-space
|
||||
grid overlay (Phase 2). Pure `_draw()`: grid lines pan/zoom with the camera (`camera.get_screen_center_position()`
|
||||
+ viewport extent / zoom), with a heavier **major line every 5 cells**; `grid_size` / `enabled`
|
||||
|
||||
@@ -409,7 +409,7 @@ The stage is intentionally **extendable**: the spawn palette is registry-driven
|
||||
**Mode management:**
|
||||
|
||||
- **Edit** (default) — `RigidBody2D` props are frozen (`freeze = true` + `freeze_mode = FREEZE_MODE_KINEMATIC`), stickmen stand as `ANIMATED` puppets, gizmos are visible, and selection is active.
|
||||
- **Play** — props unfreeze and fall, stickmen ragdoll (`set_ragdoll(true)` with `auto_recover = false`), gizmos are hidden, and selection is cleared.
|
||||
- **Play** — props unfreeze and fall, stickmen ragdoll (`set_ragdoll(true)` with `auto_recover = false`), gizmos are hidden, selection is cleared, and the spawn palette plus the Grid/Snap/Size controls are hidden (restored on returning to Edit).
|
||||
- The mode toggle button (leftmost) flips between the two; a `mode_changed(mode: int)` signal is emitted on every toggle, and the camera view persists across the switch.
|
||||
- **Restart-the-sim:** each object's position/rotation is saved whenever you place, move, or rotate it; returning to Edit restores that authored state (and zeroes prop velocity), so every Play session starts from the same authored layout. Stickmen snap straight back to standing (no stand-up glide).
|
||||
|
||||
@@ -427,18 +427,20 @@ Clicking a palette button enters **placement mode**, which shows a translucent *
|
||||
- **Left-click** selects an object (deselects the previous); click empty space to deselect.
|
||||
- **Direct drag** — click and drag an object to move it (no separate move handle); hold **Shift** while clicking to toggle its selection.
|
||||
- **Box-select** — drag on empty space draws a marquee; release selects everything inside. Hold **Shift** to add to the selection.
|
||||
- The **primary** selection shows a white bounding box and a blue **rotate** ring (around the box); drag the ring to rotate about the center. The ring is hit-tested first, so ring clicks rotate rather than move.
|
||||
- Every selected object shows a white bounding box. A blue **rotate** ring appears around a **single** selection (drag it to rotate about the center); it is hidden for multi-selections. Hold **Ctrl** while rotating to snap to 15° increments.
|
||||
- **Multi-translate** — dragging any selected object moves the whole selection together. The ring is hit-tested first, so ring clicks rotate rather than move.
|
||||
- `object_selected(nodes)` / `object_deselected()` are emitted on selection changes.
|
||||
|
||||
**Grid & snap:**
|
||||
|
||||
- A **Grid** checkbox toggles a world-space grid overlay (major line every 5 cells); a **Snap** checkbox rounds placement and dragging to the grid; a **Size** spinbox sets the cell size (1–100 px).
|
||||
- The grid only shows in **Edit** mode and is hidden during **Play**.
|
||||
- Grid size, snap, and grid visibility persist to `user://sandbox_settings.json`.
|
||||
|
||||
**Deletion & camera:**
|
||||
|
||||
- **Delete** / **Backspace** removes all selected objects (`queue_free()`) and emits `object_deleted(nodes)`.
|
||||
- **Middle-mouse drag** pans; **mouse wheel** zooms within the exported `min_zoom` (0.1) / `max_zoom` (6.0) bounds. The view persists across mode toggles.
|
||||
- **Middle-mouse drag** pans; **mouse wheel** zooms within the exported `min_zoom` (0.1) / `max_zoom` (6.0) bounds. **Touchpad**: pinch to zoom, two-finger drag to pan. The view persists across mode toggles.
|
||||
|
||||
**Status bar:** shows `Mode: EDIT/PLAY | Objects: N | Selected: <name or count>` and updates live on spawn, selection, deletion, and mode changes.
|
||||
|
||||
|
||||
@@ -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,7 +582,8 @@ func _on_hover_changed(node: Node2D) -> void:
|
||||
_gizmos.set_hover(node)
|
||||
|
||||
|
||||
func _on_transform_committed(node: Node2D) -> void:
|
||||
func _on_transform_committed(nodes: Array[Node2D]) -> void:
|
||||
for node: Node2D in nodes:
|
||||
_save_object_state(node)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user