diff --git a/AGENTS.md b/AGENTS.md index e33ddee..01ba63c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` diff --git a/README.md b/README.md index af11476..057326f 100644 --- a/README.md +++ b/README.md @@ -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: ` and updates live on spawn, selection, deletion, and mode changes. diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index 307fe71..63bdbbc 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -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] = [] diff --git a/scripts/stage_gizmos.gd b/scripts/stage_gizmos.gd index c42bf3e..148c807 100644 --- a/scripts/stage_gizmos.gd +++ b/scripts/stage_gizmos.gd @@ -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 diff --git a/scripts/stage_selection.gd b/scripts/stage_selection.gd index 863f38a..5b15918 100644 --- a/scripts/stage_selection.gd +++ b/scripts/stage_selection.gd @@ -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: