From 00173050d653d0bdad4f02f5167013603f394db3 Mon Sep 17 00:00:00 2001 From: Dean Blackborough Date: Sun, 7 Jun 2026 16:24:21 +0100 Subject: [PATCH 1/5] Add shift click to multi select No actions yet, just multi select --- NOTES.md | 16 ++-- src/prune/scene/game_object_manager.cpp | 121 +++++++++++++++++++----- src/prune/scene/game_object_manager.hpp | 12 ++- src/prune/scene/scene_interaction.cpp | 62 +++++++++++- src/prune/scene/scene_interaction.hpp | 1 + src/prune/scene/scene_renderer.cpp | 49 +++++----- src/prune/scene/scene_renderer.hpp | 10 +- src/prune/tooling/controls.cpp | 9 +- src/prune/tooling/inspector.cpp | 4 + src/prune/tooling/outliner.cpp | 11 ++- 10 files changed, 226 insertions(+), 69 deletions(-) diff --git a/NOTES.md b/NOTES.md index 783cff1..1340871 100644 --- a/NOTES.md +++ b/NOTES.md @@ -67,10 +67,10 @@ Add normal editor actions once command history exists. Initial scope: -- Delete selected authored object -- Duplicate selected authored object -- Select the duplicated object -- Offset duplicates slightly so the result is visible +- [x] Delete selected authored object +- [x] Duplicate selected authored object +- [x] Select the duplicated object +- [x] Offset duplicates slightly so the result is visible This gives undo/redo useful behaviour to prove. @@ -80,10 +80,10 @@ Add multi-select after the single-object command path is stable. Initial scope: -- Selection set instead of one selected id -- Shift-click to add/remove from the selection set -- Clear selection on empty viewport click -- Outliner multi-select support - only if it stays small +- [x] Selection set instead of one selected id +- [x] Shift-click to add/remove from the selection set +- [x] Clear selection on empty viewport click +- [x] Outliner multi-select support The first implementation should not include grouping. diff --git a/src/prune/scene/game_object_manager.cpp b/src/prune/scene/game_object_manager.cpp index 2ab59c3..e3d8388 100644 --- a/src/prune/scene/game_object_manager.cpp +++ b/src/prune/scene/game_object_manager.cpp @@ -7,8 +7,8 @@ namespace prune { void GameObjectManager::clear() noexcept { m_objects.clear(); + m_selected_ids.clear(); m_next_id = 1; - m_selected_id = k_invalid_game_object_id; } GameObjectId GameObjectManager::create_object(const GameObject& object) @@ -18,8 +18,8 @@ namespace prune { m_objects.push_back(copy); - if (m_selected_id == k_invalid_game_object_id) { - m_selected_id = copy.identity.id; + if (m_selected_ids.empty()) { + m_selected_ids.push_back(copy.identity.id); } return copy.identity.id; @@ -43,9 +43,7 @@ namespace prune { m_objects.end() ); - if (m_selected_id != k_invalid_game_object_id && get_by_id(m_selected_id) == nullptr) { - m_selected_id = k_invalid_game_object_id; - } + sanitize_selection(); return before - m_objects.size(); } @@ -67,14 +65,7 @@ namespace prune { void GameObjectManager::set_selected_id(GameObjectId id) noexcept { - if (id == k_invalid_game_object_id) { - m_selected_id = k_invalid_game_object_id; - return; - } - - if (get_by_id(id) != nullptr) { - m_selected_id = id; - } + select(id); } void GameObjectManager::set_next_id(GameObjectId next_id) noexcept @@ -94,18 +85,25 @@ namespace prune { return false; } + const bool was_active_selection = selected_id() == id; + m_objects.erase(m_objects.begin() + static_cast(index)); + remove_from_selection(id); - if (m_selected_id == id) { + if (was_active_selection && m_selected_ids.empty()) { if (m_objects.empty()) { - m_selected_id = k_invalid_game_object_id; - } else if (index < m_objects.size()) { - m_selected_id = m_objects[index].identity.id; - } else { - m_selected_id = m_objects.back().identity.id; + return true; + } + + if (index < m_objects.size()) { + m_selected_ids.push_back(m_objects[index].identity.id); + } + else { + m_selected_ids.push_back(m_objects.back().identity.id); } } + sanitize_selection(); return true; } @@ -141,29 +139,69 @@ namespace prune { GameObject* GameObjectManager::selected_object() noexcept { - return get_by_id(m_selected_id); + return get_by_id(selected_id()); } const GameObject* GameObjectManager::selected_object() const noexcept { - return get_by_id(m_selected_id); + return get_by_id(selected_id()); } void GameObjectManager::select(GameObjectId id) noexcept { + m_selected_ids.clear(); + if (id == k_invalid_game_object_id) { - m_selected_id = k_invalid_game_object_id; return; } if (get_by_id(id) != nullptr) { - m_selected_id = id; + m_selected_ids.push_back(id); } } + void GameObjectManager::toggle_selected(GameObjectId id) noexcept + { + if (id == k_invalid_game_object_id || get_by_id(id) == nullptr) { + return; + } + + const std::size_t index = find_selected_index(id); + if (index < m_selected_ids.size()) { + m_selected_ids.erase(m_selected_ids.begin() + static_cast(index)); + return; + } + + m_selected_ids.push_back(id); + } + + void GameObjectManager::clear_selection() noexcept + { + m_selected_ids.clear(); + } + GameObjectId GameObjectManager::selected_id() const noexcept { - return m_selected_id; + if (m_selected_ids.empty()) { + return k_invalid_game_object_id; + } + + return m_selected_ids.back(); + } + + bool GameObjectManager::is_selected(GameObjectId id) const noexcept + { + return find_selected_index(id) < m_selected_ids.size(); + } + + std::size_t GameObjectManager::selected_count() const noexcept + { + return m_selected_ids.size(); + } + + std::span GameObjectManager::selected_ids() const noexcept + { + return m_selected_ids; } std::vector& GameObjectManager::objects() noexcept @@ -219,4 +257,37 @@ namespace prune { return m_objects.size(); } + std::size_t GameObjectManager::find_selected_index(GameObjectId id) const noexcept + { + for (std::size_t index = 0; index < m_selected_ids.size(); ++index) { + if (m_selected_ids[index] == id) { + return index; + } + } + + return m_selected_ids.size(); + } + + void GameObjectManager::remove_from_selection(GameObjectId id) noexcept + { + m_selected_ids.erase( + std::remove(m_selected_ids.begin(), m_selected_ids.end(), id), + m_selected_ids.end() + ); + } + + void GameObjectManager::sanitize_selection() noexcept + { + m_selected_ids.erase( + std::remove_if( + m_selected_ids.begin(), + m_selected_ids.end(), + [this](GameObjectId id) { + return get_by_id(id) == nullptr; + } + ), + m_selected_ids.end() + ); + } + } diff --git a/src/prune/scene/game_object_manager.hpp b/src/prune/scene/game_object_manager.hpp index c5a21f9..75154d8 100644 --- a/src/prune/scene/game_object_manager.hpp +++ b/src/prune/scene/game_object_manager.hpp @@ -2,6 +2,8 @@ #include "prune/scene/game_object.hpp" +#include +#include #include #include @@ -26,8 +28,13 @@ namespace prune { [[nodiscard]] const GameObject* selected_object() const noexcept; void select(GameObjectId id) noexcept; + void toggle_selected(GameObjectId id) noexcept; + void clear_selection() noexcept; void set_selected_id(GameObjectId id) noexcept; [[nodiscard]] GameObjectId selected_id() const noexcept; + [[nodiscard]] bool is_selected(GameObjectId id) const noexcept; + [[nodiscard]] std::size_t selected_count() const noexcept; + [[nodiscard]] std::span selected_ids() const noexcept; void set_next_id(GameObjectId next_id) noexcept; [[nodiscard]] GameObjectId next_id() const noexcept; @@ -39,9 +46,12 @@ namespace prune { private: [[nodiscard]] std::size_t find_index_by_id(GameObjectId id) const noexcept; + [[nodiscard]] std::size_t find_selected_index(GameObjectId id) const noexcept; + void remove_from_selection(GameObjectId id) noexcept; + void sanitize_selection() noexcept; std::vector m_objects; + std::vector m_selected_ids; GameObjectId m_next_id = 1; - GameObjectId m_selected_id = k_invalid_game_object_id; }; } diff --git a/src/prune/scene/scene_interaction.cpp b/src/prune/scene/scene_interaction.cpp index d6a3a27..00c131f 100644 --- a/src/prune/scene/scene_interaction.cpp +++ b/src/prune/scene/scene_interaction.cpp @@ -5,6 +5,7 @@ #include #include +#include #include @@ -25,6 +26,7 @@ namespace prune { update_editor_camera(scene, state, camera, dt, input); handle_scene_click(scene, state, camera, input); handle_keyboard_nudge(scene, state, grid_options, input); + handle_delete_duplicate_shortcuts(scene, state, grid_options, input); } } @@ -90,12 +92,20 @@ namespace prune { GameObject* picked = pick_object_at_screen(scene, state, camera, input.mouse_x(), input.mouse_y()); + const bool shift_down = + input.is_key_down(SDL_SCANCODE_LSHIFT) || + input.is_key_down(SDL_SCANCODE_RSHIFT); + if (picked && scene.object_is_selectable(*picked)) { - state.objects.select(picked->identity.id); + if (shift_down) { + state.objects.toggle_selected(picked->identity.id); + } else { + state.objects.select(picked->identity.id); + } return; } - state.objects.set_selected_id(k_invalid_game_object_id); + state.objects.clear_selection(); } void SceneInteraction::handle_object_drag(Scene& scene, SceneState& state, SceneCamera& camera, const GridOptions& grid_options, const Input& input) @@ -241,6 +251,54 @@ namespace prune { } } + + void SceneInteraction::handle_delete_duplicate_shortcuts(Scene& scene, SceneState& state, const GridOptions& grid_options, const Input& input) + { + if (!scene_keyboard_input_enabled(state)) { + return; + } + + const bool ctrl_down = + input.is_key_down(SDL_SCANCODE_LCTRL) || + input.is_key_down(SDL_SCANCODE_RCTRL); + + GameObject* selected = state.objects.selected_object(); + if (!selected || !scene.object_is_editable(*selected)) { + return; + } + + if (selected->editor.deletable && + (input.was_key_pressed(SDL_SCANCODE_DELETE) || input.was_key_pressed(SDL_SCANCODE_BACKSPACE))) { + const GameObject deleted = *selected; + if (state.objects.remove_object(deleted.identity.id)) { + scene.record_editor_command(make_delete_object_command(deleted, deleted.identity.name)); + } + return; + } + + if (!selected->editor.cloneable || !ctrl_down || !input.was_key_pressed(SDL_SCANCODE_D)) { + return; + } + + GameObject duplicate = *selected; + duplicate.identity.name = state.objects.make_unique_name(duplicate.identity.name + " Copy", k_invalid_game_object_id); + + const float offset = static_cast( + grid_options.snap_to_grid + ? std::max(1, grid_options.grid_size) + : std::max(1, grid_options.nudge_step) + ); + + duplicate.transform.x += offset; + duplicate.transform.y += offset; + + const GameObjectId duplicate_id = state.objects.create_object(duplicate); + if (GameObject* created = state.objects.get_by_id(duplicate_id)) { + state.objects.select(duplicate_id); + scene.record_editor_command(make_create_object_command(*created, "Duplicate object")); + } + } + GameObject* SceneInteraction::pick_object_at_screen(Scene& scene, SceneState& state, const SceneCamera& camera, int screen_x, int screen_y) noexcept { const Transform world = camera.screen_to_world(state.viewport, screen_x, screen_y); diff --git a/src/prune/scene/scene_interaction.hpp b/src/prune/scene/scene_interaction.hpp index 2f43571..3ee213b 100644 --- a/src/prune/scene/scene_interaction.hpp +++ b/src/prune/scene/scene_interaction.hpp @@ -27,6 +27,7 @@ namespace prune { void handle_scene_click(Scene& scene, SceneState& state, const SceneCamera& camera, const Input& input); void handle_object_drag(Scene& scene, SceneState& state, SceneCamera& camera, const GridOptions& grid_options, const Input& input); void handle_keyboard_nudge(Scene& scene, SceneState& state, const GridOptions& grid_options, const Input& input); + void handle_delete_duplicate_shortcuts(Scene& scene, SceneState& state, const GridOptions& grid_options, const Input& input); [[nodiscard]] GameObject* pick_object_at_screen(Scene& scene, SceneState& state, const SceneCamera& camera, int screen_x, int screen_y) noexcept; [[nodiscard]] GameObject* movable_object_from_handle_at_screen(Scene& scene, SceneState& state, const SceneCamera& camera, int screen_x, int screen_y) noexcept; diff --git a/src/prune/scene/scene_renderer.cpp b/src/prune/scene/scene_renderer.cpp index 8285bfe..a728444 100644 --- a/src/prune/scene/scene_renderer.cpp +++ b/src/prune/scene/scene_renderer.cpp @@ -115,15 +115,15 @@ namespace prune { } } - void SceneRenderer::draw_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object, SDL_Rect& selected_outline, bool& has_selected_outline) + void SceneRenderer::draw_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object) { switch (object.render.type) { case RenderType::Rectangle: - draw_rectangle_object(renderer, state, camera, object, selected_outline, has_selected_outline); + draw_rectangle_object(renderer, state, camera, object); break; case RenderType::Sprite: - draw_sprite_object(renderer, state, camera, object, selected_outline, has_selected_outline); + draw_sprite_object(renderer, state, camera, object); break; default: @@ -131,7 +131,7 @@ namespace prune { } } - void SceneRenderer::draw_rectangle_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object, SDL_Rect& selected_outline, bool& has_selected_outline) const + void SceneRenderer::draw_rectangle_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object) const { SDL_Rect rect = camera.world_to_screen_rect(object); @@ -147,11 +147,9 @@ namespace prune { ); SDL_RenderFillRect(renderer, &rect); - - capture_selected_outline(state, object, rect, selected_outline, has_selected_outline); } - void SceneRenderer::draw_sprite_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object, SDL_Rect& selected_outline, bool& has_selected_outline) + void SceneRenderer::draw_sprite_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object) { SDL_Rect rect = camera.world_to_screen_rect(object); @@ -168,8 +166,6 @@ namespace prune { else { draw_sprite_fallback(renderer, rect); } - - capture_selected_outline(state, object, rect, selected_outline, has_selected_outline); } void SceneRenderer::render(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera, const GridOptions& grid_options) @@ -180,22 +176,23 @@ namespace prune { draw_grid(renderer, state.viewport, camera, grid_options); - SDL_Rect selected_outline{}; - bool has_selected_outline = false; - for (const auto& object : state.objects.objects()) { if (!object.lifecycle.active || !object.render.visible) { continue; } - draw_object(renderer, state, camera, object, selected_outline, has_selected_outline); + draw_object(renderer, state, camera, object); draw_debug_overlays(renderer, state, camera, object); } - if (state.scene_options.highlight_selected && has_selected_outline) { - const GameObject* selected = state.objects.selected_object(); - const bool movable = selected && scene.object_is_movable(*selected); - draw_selected_gizmo(renderer, selected_outline, movable); + if (state.scene_options.highlight_selected) { + for (const auto& object : state.objects.objects()) { + if (!object.lifecycle.active || !object.render.visible || !state.objects.is_selected(object.identity.id)) { + continue; + } + + draw_selected_outline(renderer, scene, state, camera, object); + } } } @@ -238,12 +235,12 @@ namespace prune { } } - void SceneRenderer::draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable) const + void SceneRenderer::draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable, bool active_selection) const { - SDL_SetRenderDrawColor(renderer, 174, 99, 242, 255); + SDL_SetRenderDrawColor(renderer, 174, 99, 242, active_selection ? 255 : 170); SDL_RenderDrawRect(renderer, &selected_outline); - if (!movable) { + if (!movable || !active_selection) { return; } @@ -265,12 +262,16 @@ namespace prune { SDL_RenderDrawLine(renderer, rect.x + rect.w, rect.y, rect.x, rect.y + rect.h); } - void SceneRenderer::capture_selected_outline(const SceneState& state, const GameObject& object, const SDL_Rect& rect, SDL_Rect& selected_outline, bool& has_selected_outline) const noexcept + void SceneRenderer::draw_selected_outline(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera, const GameObject& object) const { - if (object.identity.id == state.objects.selected_id()) { - selected_outline = editor::tools::transform_gizmo::selected_outline_rect(rect); - has_selected_outline = true; + const SDL_Rect object_rect = camera.world_to_screen_rect(object); + if (!is_rect_visible(state.viewport, object_rect)) { + return; } + + const SDL_Rect selected_outline = editor::tools::transform_gizmo::selected_outline_rect(object_rect); + const bool active_selection = object.identity.id == state.objects.selected_id(); + draw_selected_gizmo(renderer, selected_outline, scene.object_is_movable(object), active_selection); } SDL_Texture* SceneRenderer::sprite_texture(SDL_Renderer* renderer, const std::string& sprite_key) diff --git a/src/prune/scene/scene_renderer.hpp b/src/prune/scene/scene_renderer.hpp index 8cca856..da04e1a 100644 --- a/src/prune/scene/scene_renderer.hpp +++ b/src/prune/scene/scene_renderer.hpp @@ -28,13 +28,13 @@ namespace prune { [[nodiscard]] static bool is_rect_visible(const SceneViewport& viewport, const SDL_Rect& rect) noexcept; void draw_grid(SDL_Renderer* renderer, const SceneViewport& viewport, const SceneCamera& camera, const GridOptions& grid_options) const; - void draw_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object, SDL_Rect& selected_outline, bool& has_selected_outline); - void draw_rectangle_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object, SDL_Rect& selected_outline, bool& has_selected_outline) const; - void draw_sprite_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object, SDL_Rect& selected_outline, bool& has_selected_outline); + void draw_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object); + void draw_rectangle_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; + void draw_sprite_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object); void draw_sprite_fallback(SDL_Renderer* renderer, const SDL_Rect& rect) const; void draw_debug_overlays(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; - void draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable) const; - void capture_selected_outline(const SceneState& state, const GameObject& object, const SDL_Rect& rect, SDL_Rect& selected_outline, bool& has_selected_outline) const noexcept; + void draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable, bool active_selection) const; + void draw_selected_outline(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; [[nodiscard]] SDL_Texture* sprite_texture(SDL_Renderer* renderer, const std::string& sprite_key); diff --git a/src/prune/tooling/controls.cpp b/src/prune/tooling/controls.cpp index 02a5167..7a83ff1 100644 --- a/src/prune/tooling/controls.cpp +++ b/src/prune/tooling/controls.cpp @@ -23,9 +23,14 @@ namespace prune { tooling::imgui::layout::spacing(2); tooling::imgui::layout::text_wrapped("Objects"); - tooling::imgui::layout::text_wrapped("CTRL+Arrow keys move selected non-player object"); + tooling::imgui::layout::text_wrapped("CTRL+Arrow keys move the active selected object"); tooling::imgui::layout::text_wrapped("Hold Shift for larger movements"); - tooling::imgui::layout::text_wrapped("Mouse button left: Move non-player object"); + tooling::imgui::layout::text_wrapped("Delete or Backspace deletes the active selected object"); + tooling::imgui::layout::text_wrapped("CTRL+D duplicates the active selected object"); + tooling::imgui::layout::text_wrapped("Left click selects one object"); + tooling::imgui::layout::text_wrapped("Shift+Left click toggles objects in the selection set"); + tooling::imgui::layout::text_wrapped("Empty viewport click clears the selection set"); + tooling::imgui::layout::text_wrapped("Mouse button left on the active selected handle moves the object"); tooling::imgui::layout::spacing(2); diff --git a/src/prune/tooling/inspector.cpp b/src/prune/tooling/inspector.cpp index 1069eb1..2b65672 100644 --- a/src/prune/tooling/inspector.cpp +++ b/src/prune/tooling/inspector.cpp @@ -44,6 +44,10 @@ namespace prune { tooling::imgui::property_table::text("Id", std::to_string(selected->identity.id).c_str()); + if (objects.selected_count() > 1) { + tooling::imgui::property_table::text("Selection", (std::to_string(objects.selected_count()) + " objects, editing active").c_str()); + } + if (!can_rename) { tooling::imgui::property_table::text("Name", selected->identity.name.c_str()); } else { diff --git a/src/prune/tooling/outliner.cpp b/src/prune/tooling/outliner.cpp index b914511..47c010a 100644 --- a/src/prune/tooling/outliner.cpp +++ b/src/prune/tooling/outliner.cpp @@ -44,7 +44,7 @@ namespace prune { continue; } - const bool is_selected = object.identity.id == objects.selected_id(); + const bool is_selected = objects.is_selected(object.identity.id); if (!object_concept.selectable || !object.editor.selectable) { ImGui::BeginDisabled(); @@ -54,7 +54,14 @@ namespace prune { } if (ImGui::Selectable(label.c_str(), is_selected)) { - objects.select(object.identity.id); + const ImGuiIO& io = ImGui::GetIO(); + const bool shift_down = io.KeyShift; + + if (shift_down) { + objects.toggle_selected(object.identity.id); + } else { + objects.select(object.identity.id); + } } } } From c81db794491655f66eb79e10206b2cfe1ce6e495 Mon Sep 17 00:00:00 2001 From: Dean Blackborough Date: Tue, 9 Jun 2026 20:25:06 +0100 Subject: [PATCH 2/5] Add a box around the selection and highlight the primary --- NOTES.md | 6 +- src/prune/scene/scene_renderer.cpp | 101 ++++++++++++++++++++++++++++- src/prune/scene/scene_renderer.hpp | 3 + 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/NOTES.md b/NOTES.md index 1340871..36005ec 100644 --- a/NOTES.md +++ b/NOTES.md @@ -84,8 +84,12 @@ Initial scope: - [x] Shift-click to add/remove from the selection set - [x] Clear selection on empty viewport click - [x] Outliner multi-select support +- [x] Visible outline for every selected object +- [x] Stronger active-object indicator +- [x] Combined selection bounds in the viewport +- [x] Selection count in editor UI -The first implementation should not include grouping. +The first implementation should not include grouped movement or grouped object mutation. ### 5. Tool mode state diff --git a/src/prune/scene/scene_renderer.cpp b/src/prune/scene/scene_renderer.cpp index a728444..c653a8b 100644 --- a/src/prune/scene/scene_renderer.cpp +++ b/src/prune/scene/scene_renderer.cpp @@ -34,6 +34,16 @@ namespace prune { rect.y < viewport.height; } + SDL_Rect SceneRenderer::expanded_rect(const SDL_Rect& rect, int amount) noexcept + { + return SDL_Rect{ + rect.x - amount, + rect.y - amount, + rect.w + amount * 2, + rect.h + amount * 2 + }; + } + void SceneRenderer::draw_grid(SDL_Renderer* renderer, const SceneViewport& viewport, const SceneCamera& scene_camera, const GridOptions& grid_options) const { if (!grid_options.show_grid || grid_options.grid_size <= 1) { @@ -193,6 +203,8 @@ namespace prune { draw_selected_outline(renderer, scene, state, camera, object); } + + draw_multi_selection_bounds(renderer, state, camera); } } @@ -237,8 +249,18 @@ namespace prune { void SceneRenderer::draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable, bool active_selection) const { - SDL_SetRenderDrawColor(renderer, 174, 99, 242, active_selection ? 255 : 170); - SDL_RenderDrawRect(renderer, &selected_outline); + if (active_selection) { + SDL_SetRenderDrawColor(renderer, 236, 205, 255, 255); + SDL_RenderDrawRect(renderer, &selected_outline); + + const SDL_Rect active_outer = expanded_rect(selected_outline, 2); + SDL_SetRenderDrawColor(renderer, 174, 99, 242, 255); + SDL_RenderDrawRect(renderer, &active_outer); + } + else { + SDL_SetRenderDrawColor(renderer, 174, 99, 242, 170); + SDL_RenderDrawRect(renderer, &selected_outline); + } if (!movable || !active_selection) { return; @@ -246,7 +268,7 @@ namespace prune { const SDL_Rect move_handle = editor::tools::transform_gizmo::move_handle_rect(selected_outline); - SDL_SetRenderDrawColor(renderer, 174, 99, 242, 255); + SDL_SetRenderDrawColor(renderer, 236, 205, 255, 255); SDL_RenderFillRect(renderer, &move_handle); } @@ -274,6 +296,79 @@ namespace prune { draw_selected_gizmo(renderer, selected_outline, scene.object_is_movable(object), active_selection); } + + void SceneRenderer::draw_multi_selection_bounds(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera) const + { + if (state.objects.selected_count() <= 1) { + return; + } + + SDL_Rect bounds{}; + if (!selected_screen_bounds(state, camera, bounds)) { + return; + } + + const SDL_Rect outline = expanded_rect(bounds, 6); + + SDL_SetRenderDrawColor(renderer, 120, 190, 255, 230); + SDL_RenderDrawRect(renderer, &outline); + + const int handle_size = 5; + const SDL_Rect top_left{ outline.x, outline.y, handle_size, handle_size }; + const SDL_Rect top_right{ outline.x + outline.w - handle_size, outline.y, handle_size, handle_size }; + const SDL_Rect bottom_left{ outline.x, outline.y + outline.h - handle_size, handle_size, handle_size }; + const SDL_Rect bottom_right{ outline.x + outline.w - handle_size, outline.y + outline.h - handle_size, handle_size, handle_size }; + + SDL_RenderFillRect(renderer, &top_left); + SDL_RenderFillRect(renderer, &top_right); + SDL_RenderFillRect(renderer, &bottom_left); + SDL_RenderFillRect(renderer, &bottom_right); + } + + bool SceneRenderer::selected_screen_bounds(const SceneState& state, const SceneCamera& camera, SDL_Rect& bounds) const + { + bool has_bounds = false; + int min_x = 0; + int min_y = 0; + int max_x = 0; + int max_y = 0; + + for (const GameObjectId id : state.objects.selected_ids()) { + const GameObject* object = state.objects.get_by_id(id); + if (!object || !object->lifecycle.active || !object->render.visible) { + continue; + } + + const SDL_Rect object_rect = camera.world_to_screen_rect(*object); + if (!is_rect_visible(state.viewport, object_rect)) { + continue; + } + + const SDL_Rect selected_outline = editor::tools::transform_gizmo::selected_outline_rect(object_rect); + + if (!has_bounds) { + min_x = selected_outline.x; + min_y = selected_outline.y; + max_x = selected_outline.x + selected_outline.w; + max_y = selected_outline.y + selected_outline.h; + has_bounds = true; + continue; + } + + min_x = std::min(min_x, selected_outline.x); + min_y = std::min(min_y, selected_outline.y); + max_x = std::max(max_x, selected_outline.x + selected_outline.w); + max_y = std::max(max_y, selected_outline.y + selected_outline.h); + } + + if (!has_bounds) { + return false; + } + + bounds = SDL_Rect{ min_x, min_y, max_x - min_x, max_y - min_y }; + return true; + } + SDL_Texture* SceneRenderer::sprite_texture(SDL_Renderer* renderer, const std::string& sprite_key) { if (sprite_key.empty()) { diff --git a/src/prune/scene/scene_renderer.hpp b/src/prune/scene/scene_renderer.hpp index da04e1a..3b65498 100644 --- a/src/prune/scene/scene_renderer.hpp +++ b/src/prune/scene/scene_renderer.hpp @@ -26,6 +26,7 @@ namespace prune { private: [[nodiscard]] static bool is_rect_visible(const SceneViewport& viewport, const SDL_Rect& rect) noexcept; + [[nodiscard]] static SDL_Rect expanded_rect(const SDL_Rect& rect, int amount) noexcept; void draw_grid(SDL_Renderer* renderer, const SceneViewport& viewport, const SceneCamera& camera, const GridOptions& grid_options) const; void draw_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object); @@ -35,6 +36,8 @@ namespace prune { void draw_debug_overlays(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; void draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable, bool active_selection) const; void draw_selected_outline(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; + void draw_multi_selection_bounds(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera) const; + [[nodiscard]] bool selected_screen_bounds(const SceneState& state, const SceneCamera& camera, SDL_Rect& bounds) const; [[nodiscard]] SDL_Texture* sprite_texture(SDL_Renderer* renderer, const std::string& sprite_key); From 7bbf1bfe44e046d12711e1cf6ee19367b69981a3 Mon Sep 17 00:00:00 2001 From: Dean Blackborough Date: Tue, 9 Jun 2026 22:04:41 +0100 Subject: [PATCH 3/5] Adjust multi select UI, border around all with own drag handle --- NOTES.md | 2 +- src/prune/scene/scene_renderer.cpp | 34 ++++++++---------------------- src/prune/scene/scene_renderer.hpp | 2 +- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/NOTES.md b/NOTES.md index 36005ec..1e3267b 100644 --- a/NOTES.md +++ b/NOTES.md @@ -85,8 +85,8 @@ Initial scope: - [x] Clear selection on empty viewport click - [x] Outliner multi-select support - [x] Visible outline for every selected object -- [x] Stronger active-object indicator - [x] Combined selection bounds in the viewport +- [x] Multi-selection drag handle on the selection bounds - [x] Selection count in editor UI The first implementation should not include grouped movement or grouped object mutation. diff --git a/src/prune/scene/scene_renderer.cpp b/src/prune/scene/scene_renderer.cpp index c653a8b..d5cdf77 100644 --- a/src/prune/scene/scene_renderer.cpp +++ b/src/prune/scene/scene_renderer.cpp @@ -247,22 +247,12 @@ namespace prune { } } - void SceneRenderer::draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable, bool active_selection) const + void SceneRenderer::draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable) const { - if (active_selection) { - SDL_SetRenderDrawColor(renderer, 236, 205, 255, 255); - SDL_RenderDrawRect(renderer, &selected_outline); + SDL_SetRenderDrawColor(renderer, 174, 99, 242, 190); + SDL_RenderDrawRect(renderer, &selected_outline); - const SDL_Rect active_outer = expanded_rect(selected_outline, 2); - SDL_SetRenderDrawColor(renderer, 174, 99, 242, 255); - SDL_RenderDrawRect(renderer, &active_outer); - } - else { - SDL_SetRenderDrawColor(renderer, 174, 99, 242, 170); - SDL_RenderDrawRect(renderer, &selected_outline); - } - - if (!movable || !active_selection) { + if (!movable) { return; } @@ -292,8 +282,8 @@ namespace prune { } const SDL_Rect selected_outline = editor::tools::transform_gizmo::selected_outline_rect(object_rect); - const bool active_selection = object.identity.id == state.objects.selected_id(); - draw_selected_gizmo(renderer, selected_outline, scene.object_is_movable(object), active_selection); + const bool show_object_handle = state.objects.selected_count() == 1; + draw_selected_gizmo(renderer, selected_outline, show_object_handle && scene.object_is_movable(object)); } @@ -313,16 +303,10 @@ namespace prune { SDL_SetRenderDrawColor(renderer, 120, 190, 255, 230); SDL_RenderDrawRect(renderer, &outline); - const int handle_size = 5; - const SDL_Rect top_left{ outline.x, outline.y, handle_size, handle_size }; - const SDL_Rect top_right{ outline.x + outline.w - handle_size, outline.y, handle_size, handle_size }; - const SDL_Rect bottom_left{ outline.x, outline.y + outline.h - handle_size, handle_size, handle_size }; - const SDL_Rect bottom_right{ outline.x + outline.w - handle_size, outline.y + outline.h - handle_size, handle_size, handle_size }; + const SDL_Rect move_handle = editor::tools::transform_gizmo::move_handle_rect(outline); - SDL_RenderFillRect(renderer, &top_left); - SDL_RenderFillRect(renderer, &top_right); - SDL_RenderFillRect(renderer, &bottom_left); - SDL_RenderFillRect(renderer, &bottom_right); + SDL_SetRenderDrawColor(renderer, 236, 205, 255, 255); + SDL_RenderFillRect(renderer, &move_handle); } bool SceneRenderer::selected_screen_bounds(const SceneState& state, const SceneCamera& camera, SDL_Rect& bounds) const diff --git a/src/prune/scene/scene_renderer.hpp b/src/prune/scene/scene_renderer.hpp index 3b65498..077f182 100644 --- a/src/prune/scene/scene_renderer.hpp +++ b/src/prune/scene/scene_renderer.hpp @@ -34,7 +34,7 @@ namespace prune { void draw_sprite_object(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object); void draw_sprite_fallback(SDL_Renderer* renderer, const SDL_Rect& rect) const; void draw_debug_overlays(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; - void draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable, bool active_selection) const; + void draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable) const; void draw_selected_outline(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; void draw_multi_selection_bounds(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera) const; [[nodiscard]] bool selected_screen_bounds(const SceneState& state, const SceneCamera& camera, SDL_Rect& bounds) const; From b8db214e601abc2d684a9f9db91f55c87e478e75 Mon Sep 17 00:00:00 2001 From: Dean Blackborough Date: Sat, 13 Jun 2026 23:30:16 +0100 Subject: [PATCH 4/5] Updated decisions, multi select and actions --- DECISIONS.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/DECISIONS.md b/DECISIONS.md index 41f1f6b..ba82900 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -858,3 +858,28 @@ Rotation cuts across rendering, picking, collision, bounds, serialization, inspe - The next viewport transform tool after move should be scale. - Rotation remains planned but deliberately later. - Any future rotation work should start by documenting what rotates visually only and what rotates physically. + +--- + +## Defer grouped inspector edit actions + +### Decision + +Implement basic group support for move and delete, but defer grouped inspector/property edits. + +### Why + +Grouped editor commands are now supported for multi-selection move and delete, because those actions need to undo and redo atomically as a single user action. + +Grouped inspector/property edits are intentionally deferred. Changing size, colour, sprite, flags, labels, or scene-specific properties across multiple selected objects introduces additional UI and command-history complexity: + +mixed values need clear inspector behaviour +partial edits need explicit rules +command history must describe the grouped edit accurately +undo/redo must restore all affected objects as one atomic command + +For now, the inspector continues to edit the active selected object only. Multi-object mutation is limited to viewport movement and deletion. + +### Revisit when + +Tools are stable enough and we have irnoned out any issues with the multi-selection and the command history model. Then we can start to explore grouped inspector edits with a clear understanding of how the editor behaves and what the user expects. From 9f662f6902ae60d757a0f974135315d1695951e7 Mon Sep 17 00:00:00 2001 From: Dean Blackborough Date: Sat, 13 Jun 2026 23:32:37 +0100 Subject: [PATCH 5/5] Multi select - move and delete --- NOTES.md | 4 +- src/prune/editor/editor_command.cpp | 47 ++++ src/prune/editor/editor_command.hpp | 21 +- src/prune/scene/game_object_manager.cpp | 13 + src/prune/scene/game_object_manager.hpp | 1 + src/prune/scene/scene_interaction.cpp | 305 +++++++++++++++++++----- src/prune/scene/scene_interaction.hpp | 6 +- src/prune/scene/scene_renderer.cpp | 24 +- src/prune/scene/scene_renderer.hpp | 3 +- src/prune/scene/scene_state.hpp | 8 + src/prune/scene/world_scene.cpp | 39 ++- src/prune/scene/world_scene.hpp | 4 +- src/prune/tooling/command_history.cpp | 6 +- src/prune/tooling/controls.cpp | 6 +- 14 files changed, 417 insertions(+), 70 deletions(-) diff --git a/NOTES.md b/NOTES.md index 1e3267b..a117f05 100644 --- a/NOTES.md +++ b/NOTES.md @@ -88,8 +88,10 @@ Initial scope: - [x] Combined selection bounds in the viewport - [x] Multi-selection drag handle on the selection bounds - [x] Selection count in editor UI +- [x] Move selected objects as one editor command +- [x] Delete selected deletable objects as one editor command -The first implementation should not include grouped movement or grouped object mutation. +Multi-select now supports grouped viewport move and grouped delete. Grouped inspector/property edits remain deferred because they need more specific UX and command labels. ### 5. Tool mode state diff --git a/src/prune/editor/editor_command.cpp b/src/prune/editor/editor_command.cpp index 8418b31..027f99e 100644 --- a/src/prune/editor/editor_command.cpp +++ b/src/prune/editor/editor_command.cpp @@ -103,6 +103,10 @@ namespace prune { return "Change sprite"; case EditorCommandType::MoveViewport: return "Move viewport"; + case EditorCommandType::MoveObjects: + return "Move objects"; + case EditorCommandType::DeleteObjects: + return "Delete objects"; } return "Unknown editor command"; @@ -120,6 +124,7 @@ namespace prune { command.label = std::string(label); command.detail = std::string(detail); command.object_id = after.identity.id; + command.object_ids.push_back(after.identity.id); command.before_object = before; command.after_object = after; @@ -135,6 +140,7 @@ namespace prune { command.label = editor_command_type_label(EditorCommandType::CreateObject); command.detail = std::string(detail); command.object_id = created.identity.id; + command.object_ids.push_back(created.identity.id); command.after_object = created; return command; @@ -149,11 +155,52 @@ namespace prune { command.label = editor_command_type_label(EditorCommandType::DeleteObject); command.detail = std::string(detail); command.object_id = deleted.identity.id; + command.object_ids.push_back(deleted.identity.id); command.before_object = deleted; return command; } + EditorCommand make_multi_object_command( + EditorCommandType type, + std::string_view label, + std::span before, + std::span after, + std::string_view detail + ) { + EditorCommand command{}; + command.type = type; + command.label = std::string(label); + command.detail = std::string(detail); + command.before_objects.assign(before.begin(), before.end()); + command.after_objects.assign(after.begin(), after.end()); + + command.object_ids.reserve(command.after_objects.size()); + for (const GameObject& object : command.after_objects) { + command.object_ids.push_back(object.identity.id); + } + + return command; + } + + EditorCommand make_multi_delete_object_command( + std::span deleted, + std::string_view detail + ) { + EditorCommand command{}; + command.type = EditorCommandType::DeleteObjects; + command.label = editor_command_type_label(EditorCommandType::DeleteObjects); + command.detail = std::string(detail); + command.before_objects.assign(deleted.begin(), deleted.end()); + + command.object_ids.reserve(command.before_objects.size()); + for (const GameObject& object : command.before_objects) { + command.object_ids.push_back(object.identity.id); + } + + return command; + } + EditorCommand make_viewport_command( std::string_view label, const Camera& before, diff --git a/src/prune/editor/editor_command.hpp b/src/prune/editor/editor_command.hpp index 92232af..e8addec 100644 --- a/src/prune/editor/editor_command.hpp +++ b/src/prune/editor/editor_command.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -22,7 +23,9 @@ namespace prune { ChangeObjectColour, ChangeObjectFlag, ChangeSprite, - MoveViewport + MoveViewport, + MoveObjects, + DeleteObjects }; struct EditorCommand { @@ -33,6 +36,9 @@ namespace prune { std::optional before_object; std::optional after_object; + std::vector before_objects; + std::vector after_objects; + std::vector object_ids; std::optional before_camera; std::optional after_camera; @@ -80,6 +86,19 @@ namespace prune { std::string_view detail = {} ); + [[nodiscard]] EditorCommand make_multi_object_command( + EditorCommandType type, + std::string_view label, + std::span before, + std::span after, + std::string_view detail = {} + ); + + [[nodiscard]] EditorCommand make_multi_delete_object_command( + std::span deleted, + std::string_view detail = {} + ); + [[nodiscard]] EditorCommand make_viewport_command( std::string_view label, const Camera& before, diff --git a/src/prune/scene/game_object_manager.cpp b/src/prune/scene/game_object_manager.cpp index e3d8388..b7a6a02 100644 --- a/src/prune/scene/game_object_manager.cpp +++ b/src/prune/scene/game_object_manager.cpp @@ -160,6 +160,19 @@ namespace prune { } } + void GameObjectManager::select_many(std::span ids) + { + m_selected_ids.clear(); + + for (const GameObjectId id : ids) { + if (id == k_invalid_game_object_id || get_by_id(id) == nullptr || is_selected(id)) { + continue; + } + + m_selected_ids.push_back(id); + } + } + void GameObjectManager::toggle_selected(GameObjectId id) noexcept { if (id == k_invalid_game_object_id || get_by_id(id) == nullptr) { diff --git a/src/prune/scene/game_object_manager.hpp b/src/prune/scene/game_object_manager.hpp index 75154d8..af16d4e 100644 --- a/src/prune/scene/game_object_manager.hpp +++ b/src/prune/scene/game_object_manager.hpp @@ -28,6 +28,7 @@ namespace prune { [[nodiscard]] const GameObject* selected_object() const noexcept; void select(GameObjectId id) noexcept; + void select_many(std::span ids); void toggle_selected(GameObjectId id) noexcept; void clear_selection() noexcept; void set_selected_id(GameObjectId id) noexcept; diff --git a/src/prune/scene/scene_interaction.cpp b/src/prune/scene/scene_interaction.cpp index 00c131f..74a86db 100644 --- a/src/prune/scene/scene_interaction.cpp +++ b/src/prune/scene/scene_interaction.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include @@ -112,43 +113,68 @@ namespace prune { { if (state.drag_state.active) { if (!input.is_mouse_button_down(SDL_BUTTON_LEFT) || !state.viewport.has_area()) { - if (GameObject* object = state.objects.get_by_id(state.drag_state.object_id)) { + std::vector before_objects; + std::vector after_objects; + + before_objects.reserve(state.drag_state.object_starts.size()); + after_objects.reserve(state.drag_state.object_starts.size()); + + for (const DragObjectStart& start : state.drag_state.object_starts) { + GameObject* object = state.objects.get_by_id(start.object_id); + if (!object) { + continue; + } + GameObject before = *object; - before.transform = state.drag_state.object_start; - - if (before.transform.x != object->transform.x || before.transform.y != object->transform.y) { - scene.record_editor_command(make_object_command( - EditorCommandType::MoveObject, - editor_command_type_label(EditorCommandType::MoveObject), - before, - *object, - "Mouse drag" - )); + before.transform = start.transform; + + if (before.transform.x == object->transform.x && before.transform.y == object->transform.y) { + continue; } + + before_objects.push_back(before); + after_objects.push_back(*object); } - state.drag_state = {}; - return; - } + if (before_objects.size() == 1) { + scene.record_editor_command(make_object_command( + EditorCommandType::MoveObject, + editor_command_type_label(EditorCommandType::MoveObject), + before_objects.front(), + after_objects.front(), + "Mouse drag" + )); + } + else if (!before_objects.empty()) { + scene.record_editor_command(make_multi_object_command( + EditorCommandType::MoveObjects, + editor_command_type_label(EditorCommandType::MoveObjects), + before_objects, + after_objects, + std::to_string(before_objects.size()) + " objects, mouse drag" + )); + } - GameObject* object = state.objects.get_by_id(state.drag_state.object_id); - if (!object || !scene.object_is_movable(*object)) { state.drag_state = {}; return; } const Transform mouse_world = camera.screen_to_world(state.viewport, input.mouse_x(), input.mouse_y()); + const float delta_x = mouse_world.x - state.drag_state.mouse_start_world.x; + const float delta_y = mouse_world.y - state.drag_state.mouse_start_world.y; - object->transform.x = - state.drag_state.object_start.x + - (mouse_world.x - state.drag_state.mouse_start_world.x); + for (const DragObjectStart& start : state.drag_state.object_starts) { + GameObject* object = state.objects.get_by_id(start.object_id); + if (!object || !scene.object_is_movable(*object)) { + continue; + } - object->transform.y = - state.drag_state.object_start.y + - (mouse_world.y - state.drag_state.mouse_start_world.y); + object->transform.x = start.transform.x + delta_x; + object->transform.y = start.transform.y + delta_y; - if (grid_options.snap_to_grid) { - snap_object_to_grid(grid_options, *object); + if (grid_options.snap_to_grid) { + snap_object_to_grid(grid_options, *object); + } } return; @@ -166,15 +192,29 @@ namespace prune { return; } - GameObject* selected = movable_object_from_handle_at_screen(scene, state, camera, input.mouse_x(), input.mouse_y()); - if (!selected) { + const std::vector movable_ids = movable_objects_from_handle_at_screen(scene, state, camera, input.mouse_x(), input.mouse_y()); + if (movable_ids.empty()) { return; } + state.drag_state = {}; state.drag_state.active = true; - state.drag_state.object_id = selected->identity.id; - state.drag_state.object_start = selected->transform; + state.drag_state.object_id = movable_ids.front(); state.drag_state.mouse_start_world = camera.screen_to_world(state.viewport, input.mouse_x(), input.mouse_y()); + state.drag_state.object_starts.reserve(movable_ids.size()); + + for (const GameObjectId id : movable_ids) { + const GameObject* object = state.objects.get_by_id(id); + if (!object) { + continue; + } + + state.drag_state.object_starts.push_back(DragObjectStart{ id, object->transform }); + } + + if (const GameObject* object = state.objects.get_by_id(state.drag_state.object_id)) { + state.drag_state.object_start = object->transform; + } } void SceneInteraction::handle_keyboard_nudge(Scene& scene, SceneState& state, const GridOptions& grid_options, const Input& input) @@ -183,11 +223,6 @@ namespace prune { return; } - GameObject* selected = state.objects.selected_object(); - if (!selected || !scene.object_is_movable(*selected)) { - return; - } - const bool ctrl_down = input.is_key_down(SDL_SCANCODE_LCTRL) || input.is_key_down(SDL_SCANCODE_RCTRL); @@ -231,24 +266,66 @@ namespace prune { step *= grid_options.shift_nudge_steps; } - const GameObject before = *selected; + if (state.objects.selected_count() == 0) { + return; + } + + if (state.objects.selected_count() > 1) { + for (const GameObjectId id : state.objects.selected_ids()) { + const GameObject* object = state.objects.get_by_id(id); + if (!object || !scene.object_is_movable(*object)) { + return; + } + } + } + + std::vector before_objects; + std::vector after_objects; + + before_objects.reserve(state.objects.selected_count()); + after_objects.reserve(state.objects.selected_count()); + + for (const GameObjectId id : state.objects.selected_ids()) { + GameObject* object = state.objects.get_by_id(id); + if (!object || !scene.object_is_movable(*object)) { + continue; + } - selected->transform.x += static_cast(move_x * step); - selected->transform.y += static_cast(move_y * step); + const GameObject before = *object; - if (grid_options.snap_to_grid) { - snap_object_to_grid(grid_options, *selected); + object->transform.x += static_cast(move_x * step); + object->transform.y += static_cast(move_y * step); + + if (grid_options.snap_to_grid) { + snap_object_to_grid(grid_options, *object); + } + + if (before.transform.x == object->transform.x && before.transform.y == object->transform.y) { + continue; + } + + before_objects.push_back(before); + after_objects.push_back(*object); } - if (before.transform.x != selected->transform.x || before.transform.y != selected->transform.y) { + if (before_objects.size() == 1) { scene.record_editor_command(make_object_command( EditorCommandType::MoveObject, editor_command_type_label(EditorCommandType::MoveObject), - before, - *selected, + before_objects.front(), + after_objects.front(), "Keyboard nudge" )); } + else if (!before_objects.empty()) { + scene.record_editor_command(make_multi_object_command( + EditorCommandType::MoveObjects, + editor_command_type_label(EditorCommandType::MoveObjects), + before_objects, + after_objects, + std::to_string(before_objects.size()) + " objects, keyboard nudge" + )); + } } @@ -262,17 +339,45 @@ namespace prune { input.is_key_down(SDL_SCANCODE_LCTRL) || input.is_key_down(SDL_SCANCODE_RCTRL); - GameObject* selected = state.objects.selected_object(); - if (!selected || !scene.object_is_editable(*selected)) { + if (input.was_key_pressed(SDL_SCANCODE_DELETE) || input.was_key_pressed(SDL_SCANCODE_BACKSPACE)) { + std::vector deleted_objects; + deleted_objects.reserve(state.objects.selected_count()); + + for (const GameObjectId id : state.objects.selected_ids()) { + const GameObject* object = state.objects.get_by_id(id); + if (!object || !scene.object_is_editable(*object) || !object->editor.deletable) { + continue; + } + + deleted_objects.push_back(*object); + } + + if (deleted_objects.empty()) { + return; + } + + for (const GameObject& object : deleted_objects) { + state.objects.remove_object(object.identity.id); + } + + if (deleted_objects.size() > 1) { + state.objects.clear_selection(); + } + + if (deleted_objects.size() == 1) { + scene.record_editor_command(make_delete_object_command(deleted_objects.front(), deleted_objects.front().identity.name)); + } + else { + scene.record_editor_command(make_multi_delete_object_command( + deleted_objects, + std::to_string(deleted_objects.size()) + " objects" + )); + } return; } - if (selected->editor.deletable && - (input.was_key_pressed(SDL_SCANCODE_DELETE) || input.was_key_pressed(SDL_SCANCODE_BACKSPACE))) { - const GameObject deleted = *selected; - if (state.objects.remove_object(deleted.identity.id)) { - scene.record_editor_command(make_delete_object_command(deleted, deleted.identity.name)); - } + GameObject* selected = state.objects.selected_object(); + if (!selected || !scene.object_is_editable(*selected)) { return; } @@ -328,29 +433,115 @@ namespace prune { } - GameObject* SceneInteraction::movable_object_from_handle_at_screen(Scene& scene, SceneState& state, const SceneCamera& camera, int screen_x, int screen_y) noexcept + std::vector SceneInteraction::movable_objects_from_handle_at_screen(Scene& scene, SceneState& state, const SceneCamera& camera, int screen_x, int screen_y) { + std::vector movable_ids; + if (!state.scene_options.highlight_selected) { - return nullptr; + return movable_ids; + } + + const int local_mouse_x = screen_x - state.viewport.screen_x; + const int local_mouse_y = screen_y - state.viewport.screen_y; + + if (state.objects.selected_count() > 1) { + SDL_Rect bounds{}; + if (!selected_screen_bounds(state, camera, bounds)) { + return movable_ids; + } + + const SDL_Rect outline{ + bounds.x - 6, + bounds.y - 6, + bounds.w + 12, + bounds.h + 12 + }; + + const SDL_Rect move_handle = editor::tools::transform_gizmo::move_handle_rect(outline); + if (!editor::tools::transform_gizmo::contains_point(move_handle, local_mouse_x, local_mouse_y)) { + return movable_ids; + } + + movable_ids.reserve(state.objects.selected_count()); + for (const GameObjectId id : state.objects.selected_ids()) { + const GameObject* object = state.objects.get_by_id(id); + if (!object || !scene.object_is_movable(*object)) { + return {}; + } + + movable_ids.push_back(id); + } + + return movable_ids; } GameObject* selected = state.objects.selected_object(); if (!selected || !scene.object_is_movable(*selected)) { - return nullptr; + return movable_ids; } const SDL_Rect object_rect = camera.world_to_screen_rect(*selected); const SDL_Rect selected_outline = editor::tools::transform_gizmo::selected_outline_rect(object_rect); const SDL_Rect move_handle = editor::tools::transform_gizmo::move_handle_rect(selected_outline); - const int local_mouse_x = screen_x - state.viewport.screen_x; - const int local_mouse_y = screen_y - state.viewport.screen_y; - if (!editor::tools::transform_gizmo::contains_point(move_handle, local_mouse_x, local_mouse_y)) { - return nullptr; + return movable_ids; } - return selected; + movable_ids.push_back(selected->identity.id); + return movable_ids; + } + + bool SceneInteraction::selected_screen_bounds(const SceneState& state, const SceneCamera& camera, SDL_Rect& bounds) noexcept + { + bool has_bounds = false; + int min_x = 0; + int min_y = 0; + int max_x = 0; + int max_y = 0; + + for (const GameObjectId id : state.objects.selected_ids()) { + const GameObject* object = state.objects.get_by_id(id); + if (!object || !object->lifecycle.active || !object->render.visible) { + continue; + } + + const SDL_Rect object_rect = camera.world_to_screen_rect(*object); + if (!rect_visible(state.viewport, object_rect)) { + continue; + } + + const SDL_Rect selected_outline = editor::tools::transform_gizmo::selected_outline_rect(object_rect); + + if (!has_bounds) { + min_x = selected_outline.x; + min_y = selected_outline.y; + max_x = selected_outline.x + selected_outline.w; + max_y = selected_outline.y + selected_outline.h; + has_bounds = true; + continue; + } + + min_x = std::min(min_x, selected_outline.x); + min_y = std::min(min_y, selected_outline.y); + max_x = std::max(max_x, selected_outline.x + selected_outline.w); + max_y = std::max(max_y, selected_outline.y + selected_outline.h); + } + + if (!has_bounds) { + return false; + } + + bounds = SDL_Rect{ min_x, min_y, max_x - min_x, max_y - min_y }; + return true; + } + + bool SceneInteraction::rect_visible(const SceneViewport& viewport, const SDL_Rect& rect) noexcept + { + return rect.x + rect.w >= 0 && + rect.y + rect.h >= 0 && + rect.x < viewport.width && + rect.y < viewport.height; } float SceneInteraction::snap_value_to_grid(const GridOptions& grid_options, float value) noexcept diff --git a/src/prune/scene/scene_interaction.hpp b/src/prune/scene/scene_interaction.hpp index 3ee213b..9e633aa 100644 --- a/src/prune/scene/scene_interaction.hpp +++ b/src/prune/scene/scene_interaction.hpp @@ -4,6 +4,8 @@ #include "prune/scene/scene_camera.hpp" #include "prune/scene/scene_state.hpp" +#include + namespace prune { class Scene; @@ -30,7 +32,9 @@ namespace prune { void handle_delete_duplicate_shortcuts(Scene& scene, SceneState& state, const GridOptions& grid_options, const Input& input); [[nodiscard]] GameObject* pick_object_at_screen(Scene& scene, SceneState& state, const SceneCamera& camera, int screen_x, int screen_y) noexcept; - [[nodiscard]] GameObject* movable_object_from_handle_at_screen(Scene& scene, SceneState& state, const SceneCamera& camera, int screen_x, int screen_y) noexcept; + [[nodiscard]] std::vector movable_objects_from_handle_at_screen(Scene& scene, SceneState& state, const SceneCamera& camera, int screen_x, int screen_y); + [[nodiscard]] static bool selected_screen_bounds(const SceneState& state, const SceneCamera& camera, SDL_Rect& bounds) noexcept; + [[nodiscard]] static bool rect_visible(const SceneViewport& viewport, const SDL_Rect& rect) noexcept; [[nodiscard]] static float snap_value_to_grid( const GridOptions& grid_options, diff --git a/src/prune/scene/scene_renderer.cpp b/src/prune/scene/scene_renderer.cpp index d5cdf77..d05d273 100644 --- a/src/prune/scene/scene_renderer.cpp +++ b/src/prune/scene/scene_renderer.cpp @@ -204,7 +204,7 @@ namespace prune { draw_selected_outline(renderer, scene, state, camera, object); } - draw_multi_selection_bounds(renderer, state, camera); + draw_multi_selection_bounds(renderer, scene, state, camera); } } @@ -287,7 +287,7 @@ namespace prune { } - void SceneRenderer::draw_multi_selection_bounds(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera) const + void SceneRenderer::draw_multi_selection_bounds(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera) const { if (state.objects.selected_count() <= 1) { return; @@ -303,12 +303,32 @@ namespace prune { SDL_SetRenderDrawColor(renderer, 120, 190, 255, 230); SDL_RenderDrawRect(renderer, &outline); + if (!multi_selection_is_movable(scene, state)) { + return; + } + const SDL_Rect move_handle = editor::tools::transform_gizmo::move_handle_rect(outline); SDL_SetRenderDrawColor(renderer, 236, 205, 255, 255); SDL_RenderFillRect(renderer, &move_handle); } + bool SceneRenderer::multi_selection_is_movable(const Scene& scene, const SceneState& state) const + { + if (state.objects.selected_count() <= 1) { + return false; + } + + for (const GameObjectId id : state.objects.selected_ids()) { + const GameObject* object = state.objects.get_by_id(id); + if (!object || !scene.object_is_movable(*object)) { + return false; + } + } + + return true; + } + bool SceneRenderer::selected_screen_bounds(const SceneState& state, const SceneCamera& camera, SDL_Rect& bounds) const { bool has_bounds = false; diff --git a/src/prune/scene/scene_renderer.hpp b/src/prune/scene/scene_renderer.hpp index 077f182..f4e8d0c 100644 --- a/src/prune/scene/scene_renderer.hpp +++ b/src/prune/scene/scene_renderer.hpp @@ -36,8 +36,9 @@ namespace prune { void draw_debug_overlays(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; void draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable) const; void draw_selected_outline(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera, const GameObject& object) const; - void draw_multi_selection_bounds(SDL_Renderer* renderer, const SceneState& state, const SceneCamera& camera) const; + void draw_multi_selection_bounds(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera) const; [[nodiscard]] bool selected_screen_bounds(const SceneState& state, const SceneCamera& camera, SDL_Rect& bounds) const; + [[nodiscard]] bool multi_selection_is_movable(const Scene& scene, const SceneState& state) const; [[nodiscard]] SDL_Texture* sprite_texture(SDL_Renderer* renderer, const std::string& sprite_key); diff --git a/src/prune/scene/scene_state.hpp b/src/prune/scene/scene_state.hpp index 73968d3..40cf190 100644 --- a/src/prune/scene/scene_state.hpp +++ b/src/prune/scene/scene_state.hpp @@ -5,6 +5,8 @@ #include "prune/scene/game_object.hpp" #include "prune/scene/game_object_manager.hpp" +#include + namespace prune { struct SceneViewport { @@ -52,10 +54,16 @@ namespace prune { int max_nudge_step = 64; }; + struct DragObjectStart { + GameObjectId object_id = k_invalid_game_object_id; + Transform transform{}; + }; + struct DragState { bool active = false; GameObjectId object_id = k_invalid_game_object_id; Transform object_start{}; + std::vector object_starts; Transform mouse_start_world{}; }; diff --git a/src/prune/scene/world_scene.cpp b/src/prune/scene/world_scene.cpp index b4e6ca7..316ff33 100644 --- a/src/prune/scene/world_scene.cpp +++ b/src/prune/scene/world_scene.cpp @@ -282,7 +282,7 @@ namespace prune { } - void WorldScene::restore_object_snapshot(const GameObject& object) + void WorldScene::restore_object_snapshot(const GameObject& object, bool select_restored) { if (GameObject* existing = m_state.objects.get_by_id(object.identity.id)) { *existing = object; @@ -295,7 +295,22 @@ namespace prune { ); } - m_state.objects.select(object.identity.id); + if (select_restored) { + m_state.objects.select(object.identity.id); + } + } + + void WorldScene::restore_object_snapshots(const std::vector& objects) + { + std::vector restored_ids; + restored_ids.reserve(objects.size()); + + for (const GameObject& object : objects) { + restore_object_snapshot(object, false); + restored_ids.push_back(object.identity.id); + } + + m_state.objects.select_many(restored_ids); } void WorldScene::apply_editor_command(const EditorCommand& command, bool use_after_state) @@ -319,6 +334,26 @@ namespace prune { } break; + case EditorCommandType::DeleteObjects: + if (use_after_state) { + for (const GameObjectId id : command.object_ids) { + m_state.objects.remove_object(id); + } + + m_state.objects.clear_selection(); + } else { + restore_object_snapshots(command.before_objects); + } + break; + + case EditorCommandType::MoveObjects: + if (use_after_state) { + restore_object_snapshots(command.after_objects); + } else { + restore_object_snapshots(command.before_objects); + } + break; + case EditorCommandType::MoveViewport: if (use_after_state) { if (command.after_camera.has_value()) { diff --git a/src/prune/scene/world_scene.hpp b/src/prune/scene/world_scene.hpp index 71457c9..42e4891 100644 --- a/src/prune/scene/world_scene.hpp +++ b/src/prune/scene/world_scene.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -69,7 +70,8 @@ namespace prune { private: void sanitize_loaded_selection() noexcept; - void restore_object_snapshot(const GameObject& object); + void restore_object_snapshot(const GameObject& object, bool select_restored = true); + void restore_object_snapshots(const std::vector& objects); void apply_editor_command(const EditorCommand& command, bool use_after_state); SceneRenderer m_renderer; diff --git a/src/prune/tooling/command_history.cpp b/src/prune/tooling/command_history.cpp index 152c561..43a915a 100644 --- a/src/prune/tooling/command_history.cpp +++ b/src/prune/tooling/command_history.cpp @@ -69,7 +69,11 @@ namespace prune { ); } - if (command.object_id != k_invalid_game_object_id) { + if (command.object_ids.size() > 1) { + ImGui::SameLine(); + ImGui::TextDisabled("%zu objects", command.object_ids.size()); + } + else if (command.object_id != k_invalid_game_object_id) { ImGui::SameLine(); ImGui::TextDisabled("#%u", static_cast(command.object_id)); } diff --git a/src/prune/tooling/controls.cpp b/src/prune/tooling/controls.cpp index 7a83ff1..e47ad2f 100644 --- a/src/prune/tooling/controls.cpp +++ b/src/prune/tooling/controls.cpp @@ -23,14 +23,14 @@ namespace prune { tooling::imgui::layout::spacing(2); tooling::imgui::layout::text_wrapped("Objects"); - tooling::imgui::layout::text_wrapped("CTRL+Arrow keys move the active selected object"); + tooling::imgui::layout::text_wrapped("CTRL+Arrow keys move the selected object or selected objects"); tooling::imgui::layout::text_wrapped("Hold Shift for larger movements"); - tooling::imgui::layout::text_wrapped("Delete or Backspace deletes the active selected object"); + tooling::imgui::layout::text_wrapped("Delete or Backspace deletes selected deletable objects"); tooling::imgui::layout::text_wrapped("CTRL+D duplicates the active selected object"); tooling::imgui::layout::text_wrapped("Left click selects one object"); tooling::imgui::layout::text_wrapped("Shift+Left click toggles objects in the selection set"); tooling::imgui::layout::text_wrapped("Empty viewport click clears the selection set"); - tooling::imgui::layout::text_wrapped("Mouse button left on the active selected handle moves the object"); + tooling::imgui::layout::text_wrapped("Mouse button left on a selection handle moves the selected object or selected objects"); tooling::imgui::layout::spacing(2);