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. diff --git a/NOTES.md b/NOTES.md index 783cff1..a117f05 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,12 +80,18 @@ 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 - -The first implementation should not include grouping. +- [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 +- [x] Visible outline for every selected object +- [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 + +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 2ab59c3..b7a6a02 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,82 @@ 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::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) { + 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 +270,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..af16d4e 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,14 @@ 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; [[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 +47,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..74a86db 100644 --- a/src/prune/scene/scene_interaction.cpp +++ b/src/prune/scene/scene_interaction.cpp @@ -5,6 +5,8 @@ #include #include +#include +#include #include @@ -25,6 +27,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,55 +93,88 @@ 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) { 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; @@ -156,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) @@ -173,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); @@ -221,24 +266,142 @@ 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; + } + + const GameObject before = *object; + + 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); + } - selected->transform.x += static_cast(move_x * step); - selected->transform.y += static_cast(move_y * step); + if (before.transform.x == object->transform.x && before.transform.y == object->transform.y) { + continue; + } - if (grid_options.snap_to_grid) { - snap_object_to_grid(grid_options, *selected); + 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" + )); + } + } + + + 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); + + 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; + } + + GameObject* selected = state.objects.selected_object(); + if (!selected || !scene.object_is_editable(*selected)) { + 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 @@ -270,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 2f43571..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; @@ -27,9 +29,12 @@ 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; + [[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 8285bfe..d05d273 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) { @@ -115,15 +125,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 +141,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 +157,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 +176,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 +186,25 @@ 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); + } + + draw_multi_selection_bounds(renderer, scene, state, camera); } } @@ -240,7 +249,7 @@ namespace prune { void SceneRenderer::draw_selected_gizmo(SDL_Renderer* renderer, const SDL_Rect& selected_outline, bool movable) const { - SDL_SetRenderDrawColor(renderer, 174, 99, 242, 255); + SDL_SetRenderDrawColor(renderer, 174, 99, 242, 190); SDL_RenderDrawRect(renderer, &selected_outline); if (!movable) { @@ -249,7 +258,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); } @@ -265,12 +274,103 @@ 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 + { + 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 show_object_handle = state.objects.selected_count() == 1; + draw_selected_gizmo(renderer, selected_outline, show_object_handle && scene.object_is_movable(object)); + } + + + void SceneRenderer::draw_multi_selection_bounds(SDL_Renderer* renderer, const Scene& scene, const SceneState& state, const SceneCamera& camera) const { - if (object.identity.id == state.objects.selected_id()) { - selected_outline = editor::tools::transform_gizmo::selected_outline_rect(rect); - has_selected_outline = true; + 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); + + 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; + 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) diff --git a/src/prune/scene/scene_renderer.hpp b/src/prune/scene/scene_renderer.hpp index 8cca856..f4e8d0c 100644 --- a/src/prune/scene/scene_renderer.hpp +++ b/src/prune/scene/scene_renderer.hpp @@ -26,15 +26,19 @@ 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, 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_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 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 02a5167..e47ad2f 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 selected object or selected objects"); 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 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 a selection handle moves the selected object or selected objects"); 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); + } } } }