diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index a081b01..c741b00 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -16,6 +16,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h + ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h PARENT_SCOPE ) @@ -29,6 +30,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp PARENT_SCOPE ) diff --git a/src/lib/core/SelectionController.cpp b/src/lib/core/SelectionController.cpp new file mode 100644 index 0000000..ee308a6 --- /dev/null +++ b/src/lib/core/SelectionController.cpp @@ -0,0 +1,165 @@ +#include "SelectionController.h" + +#include +#include + +#include "DebrisSelectionChangedEvent.h" +#include "EntitySelectionChangedEvent.h" +#include "EventManager.h" +#include "SelectionChangedEvent.h" + +namespace +{ + +template +bool contains(const std::vector& items, const T& item) +{ + return std::find(items.begin(), items.end(), item) != items.end(); +} + +// Applies `hits` to `selection` per `mode`. Replace is handled by the caller so +// that an empty hit list can mean "clear this category" there but "leave this +// category alone" here. +template +void combine(std::vector& selection, const std::vector& hits, SelectionMode mode) +{ + for (const T& hit : hits) + { + const typename std::vector::iterator it = + std::find(selection.begin(), selection.end(), hit); + if (it == selection.end()) + { + selection.push_back(hit); + } + else if (mode == SelectionMode::Toggle) + { + // Only a toggle removes; an additive box drag never deselects. + selection.erase(it); + } + } +} + +} // namespace + +const std::vector& SelectionController::getSelectedBuildings() const +{ + return m_buildings; +} + +const std::vector& SelectionController::getSelectedActors() const +{ + return m_actors; +} + +const std::vector& SelectionController::getSelectedDebris() const +{ + return m_debris; +} + +bool SelectionController::isActorSelected(entt::entity actor) const +{ + return contains(m_actors, actor); +} + +bool SelectionController::isDebrisSelected(entt::entity debris) const +{ + return contains(m_debris, debris); +} + +void SelectionController::selectBuildings(const std::vector& ids, + SelectionMode mode) +{ + // Buildings win over field objects (REQ-UI-SELECTION-CATEGORIES). + if (clearActorsQuietly()) { publishActors(); } + if (clearDebrisQuietly()) { publishDebris(); } + + if (mode == SelectionMode::Replace) + { + m_buildings = ids; + } + else + { + combine(m_buildings, ids, mode); + } + publishBuildings(); +} + +void SelectionController::selectFieldObjects(const std::vector& actors, + const std::vector& debris, + SelectionMode mode) +{ + if (clearBuildingsQuietly()) { publishBuildings(); } + + if (mode == SelectionMode::Replace) + { + m_actors = actors; + m_debris = debris; + } + else + { + combine(m_actors, actors, mode); + combine(m_debris, debris, mode); + } + publishActors(); + publishDebris(); +} + +void SelectionController::clearAll() +{ + if (clearBuildingsQuietly()) { publishBuildings(); } + if (clearActorsQuietly()) { publishActors(); } + if (clearDebrisQuietly()) { publishDebris(); } +} + +void SelectionController::setSelectedActors(std::vector actors) +{ + if (actors == m_actors) { return; } + m_actors = std::move(actors); + publishActors(); +} + +void SelectionController::setSelectedDebris(std::vector debris) +{ + if (debris == m_debris) { return; } + m_debris = std::move(debris); + publishDebris(); +} + +bool SelectionController::clearBuildingsQuietly() +{ + if (m_buildings.empty()) { return false; } + m_buildings.clear(); + return true; +} + +bool SelectionController::clearActorsQuietly() +{ + if (m_actors.empty()) { return false; } + m_actors.clear(); + return true; +} + +bool SelectionController::clearDebrisQuietly() +{ + if (m_debris.empty()) { return false; } + m_debris.clear(); + return true; +} + +void SelectionController::publishBuildings() const +{ + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_buildings)); +} + +void SelectionController::publishActors() const +{ + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_actors)); +} + +void SelectionController::publishDebris() const +{ + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_debris)); +} diff --git a/src/lib/core/SelectionController.h b/src/lib/core/SelectionController.h new file mode 100644 index 0000000..7f0827f --- /dev/null +++ b/src/lib/core/SelectionController.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +#include "BuildingId.h" +#include "entt/entity/entity.hpp" + +// How a new hit combines with what is already selected. +enum class SelectionMode +{ + Replace, // plain click or drag: the hit becomes the whole selection + Toggle, // Ctrl + click: the hit joins the selection, or leaves it if present + Add // Ctrl + box drag: the hits join the selection, never leave it +}; + +// The player's current selection across the three categories, and the rules that +// govern moving between them (REQ-UI-SELECTION-CATEGORIES). +// +// The rules were previously written out once for point-clicks and once for box +// drags, which is why they live here now: buildings win over field objects, so +// selecting a building clears actors and debris, and selecting either of those +// clears buildings — but actors and debris coexist with each other +// (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT, REQ-UI-MULTI-SELECT, +// REQ-UI-DEBRIS-MULTI-SELECT). A point-click and a box drag then differ only in +// the SelectionMode they pass and in how many hits they pass. +// +// Every mutator publishes the change events, so callers never emit them by hand. +// Hit-testing is not done here: callers resolve what was hit and pass the result +// in, which keeps this free of any simulation dependency. +class SelectionController +{ +public: + const std::vector& getSelectedBuildings() const; + const std::vector& getSelectedActors() const; + const std::vector& getSelectedDebris() const; + + bool isActorSelected(entt::entity actor) const; + bool isDebrisSelected(entt::entity debris) const; + + // Selects buildings and/or construction sites, clearing any field selection. + // Always publishes SelectionChangedEvent, even when the result is unchanged, + // so a click on an already-selected building still refreshes its panel. + void selectBuildings(const std::vector& ids, SelectionMode mode); + + // Selects field objects, clearing any building selection. Actors and debris are + // set together so a Replace can express "these actors and no debris" — which is + // what a plain click on an actor means — while a Toggle or Add leaves the + // category whose vector is empty untouched. Always publishes both field events. + void selectFieldObjects(const std::vector& actors, + const std::vector& debris, + SelectionMode mode); + + // Empties all three categories, publishing only for those that were non-empty. + void clearAll(); + + // Replace one category outright, publishing only if it actually changed. For + // the per-frame prune of entities that despawned or died: the liveness query + // belongs to the caller, which is the one that can see the simulation. + void setSelectedActors(std::vector actors); + void setSelectedDebris(std::vector debris); + +private: + // Each returns whether anything changed, without publishing. + bool clearBuildingsQuietly(); + bool clearActorsQuietly(); + bool clearDebrisQuietly(); + + void publishBuildings() const; + void publishActors() const; + void publishDebris() const; + + std::vector m_buildings; + std::vector m_actors; + std::vector m_debris; +}; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index b3771a2..f9a432b 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -14,6 +14,7 @@ add_files( TunnelCompletionTest.cpp WorldCoordinatesTest.cpp WorldCameraTest.cpp + SelectionControllerTest.cpp BuildingTest.cpp BuildingConfigTest.cpp ShipTest.cpp diff --git a/src/test/SelectionControllerTest.cpp b/src/test/SelectionControllerTest.cpp new file mode 100644 index 0000000..bf05be6 --- /dev/null +++ b/src/test/SelectionControllerTest.cpp @@ -0,0 +1,282 @@ +#include "catch.hpp" + +#include +#include + +#include "DebrisSelectionChangedEvent.h" +#include "EntitySelectionChangedEvent.h" +#include "EventHandler.h" +#include "EventManager.h" +#include "SelectionChangedEvent.h" +#include "SelectionController.h" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Counts the change events the controller publishes, so the tests can assert not +// just the resulting selection but that the widgets were told about it — and, for +// the categories a change did not touch, that they were not told needlessly. +class SelectionEventSpy : public CombinedEventHandler +{ +public: + SelectionEventSpy() { registerForEvents(); } + ~SelectionEventSpy() { unregisterForEvents(); } + + int buildingEvents = 0; + int actorEvents = 0; + int debrisEvents = 0; + + void reset() { buildingEvents = 0; actorEvents = 0; debrisEvents = 0; } + +private: + void handleEvent(std::shared_ptr /*event*/) override + { + ++buildingEvents; + } + void handleEvent(std::shared_ptr /*event*/) override + { + ++actorEvents; + } + void handleEvent(std::shared_ptr /*event*/) override + { + ++debrisEvents; + } +}; + +static entt::entity makeEntity(int index) +{ + return static_cast(index); +} + +// --------------------------------------------------------------------------- +// Category precedence (REQ-UI-SELECTION-CATEGORIES) +// --------------------------------------------------------------------------- + +TEST_CASE("Selecting a building clears actors and debris", "[selection]") +{ + SelectionController controller; + controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)}, + SelectionMode::Replace); + + controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace); + + REQUIRE(controller.getSelectedBuildings() == std::vector{BuildingId(7)}); + REQUIRE(controller.getSelectedActors().empty()); + REQUIRE(controller.getSelectedDebris().empty()); +} + +TEST_CASE("Selecting a field object clears buildings", "[selection]") +{ + SelectionController controller; + controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace); + + controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Replace); + + REQUIRE(controller.getSelectedBuildings().empty()); + REQUIRE(controller.getSelectedActors() == std::vector{makeEntity(1)}); +} + +TEST_CASE("Actors and debris coexist", "[selection]") +{ + // The one pair of categories that does not evict each other + // (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT). + SelectionController controller; + controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)}, + SelectionMode::Replace); + + REQUIRE(controller.getSelectedActors() == std::vector{makeEntity(1)}); + REQUIRE(controller.getSelectedDebris() == std::vector{makeEntity(2)}); +} + +TEST_CASE("A plain click on an actor drops any selected debris", "[selection]") +{ + // A point click passes only the category it hit, so Replace with an empty + // debris list is what "this actor and nothing else" means + // (REQ-UI-ENTITY-CLICK-SELECT). + SelectionController controller; + controller.selectFieldObjects({}, {makeEntity(2)}, SelectionMode::Replace); + + controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Replace); + + REQUIRE(controller.getSelectedActors() == std::vector{makeEntity(1)}); + REQUIRE(controller.getSelectedDebris().empty()); +} + +// --------------------------------------------------------------------------- +// Replace / Toggle / Add +// --------------------------------------------------------------------------- + +TEST_CASE("Replace makes the hit the whole selection", "[selection]") +{ + SelectionController controller; + controller.selectBuildings({BuildingId(1), BuildingId(2)}, SelectionMode::Replace); + + controller.selectBuildings({BuildingId(3)}, SelectionMode::Replace); + + REQUIRE(controller.getSelectedBuildings() == std::vector{BuildingId(3)}); +} + +TEST_CASE("Toggle adds a building that was not selected", "[selection]") +{ + SelectionController controller; + controller.selectBuildings({BuildingId(1)}, SelectionMode::Replace); + + controller.selectBuildings({BuildingId(2)}, SelectionMode::Toggle); + + REQUIRE(controller.getSelectedBuildings() + == std::vector{BuildingId(1), BuildingId(2)}); +} + +TEST_CASE("Toggle removes a building that was already selected", "[selection]") +{ + // Ctrl+clicking a selected building deselects it, leaving the rest alone. + SelectionController controller; + controller.selectBuildings({BuildingId(1), BuildingId(2), BuildingId(3)}, + SelectionMode::Replace); + + controller.selectBuildings({BuildingId(2)}, SelectionMode::Toggle); + + REQUIRE(controller.getSelectedBuildings() + == std::vector{BuildingId(1), BuildingId(3)}); +} + +TEST_CASE("Add never deselects", "[selection]") +{ + // This is where a Ctrl box drag differs from a Ctrl click: dragging over + // already-selected buildings must not toggle them off (REQ-UI-MULTI-SELECT). + SelectionController controller; + controller.selectBuildings({BuildingId(1), BuildingId(2)}, SelectionMode::Replace); + + controller.selectBuildings({BuildingId(2), BuildingId(3)}, SelectionMode::Add); + + REQUIRE(controller.getSelectedBuildings() + == std::vector{BuildingId(1), BuildingId(2), BuildingId(3)}); +} + +TEST_CASE("An additive field selection leaves the untouched category alone", + "[selection]") +{ + // Ctrl+clicking an actor passes an empty debris list, which must mean "do not + // touch debris" rather than "clear debris" (REQ-UI-DEBRIS-MULTI-SELECT). + SelectionController controller; + controller.selectFieldObjects({}, {makeEntity(5)}, SelectionMode::Replace); + + controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Toggle); + + REQUIRE(controller.getSelectedActors() == std::vector{makeEntity(1)}); + REQUIRE(controller.getSelectedDebris() == std::vector{makeEntity(5)}); +} + +// --------------------------------------------------------------------------- +// Membership queries used by the renderer +// --------------------------------------------------------------------------- + +TEST_CASE("Membership queries answer per category", "[selection]") +{ + SelectionController controller; + controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)}, + SelectionMode::Replace); + + REQUIRE(controller.isActorSelected(makeEntity(1))); + REQUIRE_FALSE(controller.isActorSelected(makeEntity(2))); + REQUIRE(controller.isDebrisSelected(makeEntity(2))); + REQUIRE_FALSE(controller.isDebrisSelected(makeEntity(1))); +} + +// --------------------------------------------------------------------------- +// Published events +// --------------------------------------------------------------------------- + +TEST_CASE("Selecting a building announces the categories it cleared", "[selection]") +{ + SelectionController controller; + controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)}, + SelectionMode::Replace); + + SelectionEventSpy spy; + controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace); + + REQUIRE(spy.buildingEvents == 1); + REQUIRE(spy.actorEvents == 1); + REQUIRE(spy.debrisEvents == 1); +} + +TEST_CASE("Clearing an already-empty category announces nothing", "[selection]") +{ + // The panels re-read on every event, so a redundant one is only noise — but the + // point-click and box paths used to disagree about this, so it is pinned. + SelectionController controller; + + SelectionEventSpy spy; + controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace); + + REQUIRE(spy.buildingEvents == 1); + REQUIRE(spy.actorEvents == 0); + REQUIRE(spy.debrisEvents == 0); +} + +TEST_CASE("Re-selecting the same building still announces it", "[selection]") +{ + // Clicking an already-selected building refreshes its panel, so the event + // fires even though the selection did not change. + SelectionController controller; + controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace); + + SelectionEventSpy spy; + controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace); + + REQUIRE(spy.buildingEvents == 1); +} + +TEST_CASE("Clearing an empty selection announces nothing at all", "[selection]") +{ + SelectionController controller; + + SelectionEventSpy spy; + controller.clearAll(); + + REQUIRE(spy.buildingEvents == 0); + REQUIRE(spy.actorEvents == 0); + REQUIRE(spy.debrisEvents == 0); +} + +TEST_CASE("Clearing a populated selection announces every populated category", + "[selection]") +{ + SelectionController controller; + controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)}, + SelectionMode::Replace); + + SelectionEventSpy spy; + controller.clearAll(); + + REQUIRE(spy.buildingEvents == 0); // buildings were already empty + REQUIRE(spy.actorEvents == 1); + REQUIRE(spy.debrisEvents == 1); + REQUIRE(controller.getSelectedActors().empty()); + REQUIRE(controller.getSelectedDebris().empty()); +} + +// --------------------------------------------------------------------------- +// Pruning despawned entities +// --------------------------------------------------------------------------- + +TEST_CASE("Pruning announces only when something actually went", "[selection]") +{ + // Runs every frame, so re-announcing an unchanged selection would spam the + // panels 60 times a second. + SelectionController controller; + controller.selectFieldObjects({makeEntity(1), makeEntity(2)}, {}, + SelectionMode::Replace); + + SelectionEventSpy spy; + controller.setSelectedActors({makeEntity(1), makeEntity(2)}); + REQUIRE(spy.actorEvents == 0); + + controller.setSelectedActors({makeEntity(1)}); + REQUIRE(spy.actorEvents == 1); + REQUIRE(controller.getSelectedActors() == std::vector{makeEntity(1)}); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 9c272df..ff50359 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -46,7 +46,6 @@ #include "ReplayRecorder.h" #include "DeconstructModeChangedEvent.h" #include "EntityHitTest.h" -#include "EntitySelectionChangedEvent.h" #include "EventManager.h" #include "FacingComponent.h" #include "FactionComponent.h" @@ -58,7 +57,6 @@ #include "PositionComponent.h" #include "RepairBehavior.h" #include "SalvageScrapBehavior.h" -#include "DebrisSelectionChangedEvent.h" #include "DebrisSystem.h" #include "SelectionChangedEvent.h" #include "SensorRangeComponent.h" @@ -675,51 +673,35 @@ std::optional GameWorldView::entityPosition(entt::entity entity) cons return m_sim->getAdmin().get(entity).value; } -void GameWorldView::clearDebrisSelection() -{ - if (m_selectedDebris.empty()) { return; } - m_selectedDebris.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedDebris)); -} - void GameWorldView::pruneDespawnedDebris() { - if (m_selectedDebris.empty()) { return; } + const std::vector& selected = m_selection.getSelectedDebris(); + if (selected.empty()) { return; } + // Keeps the debris that still exist, in the order the simulation reports them. std::vector live; for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin())) { - if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity) - != m_selectedDebris.end()) + if (std::find(selected.begin(), selected.end(), info.entity) != selected.end()) { live.push_back(info.entity); } } - if (live.size() != m_selectedDebris.size()) + if (live.size() != selected.size()) { - m_selectedDebris = std::move(live); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedDebris)); + m_selection.setSelectedDebris(std::move(live)); } } -void GameWorldView::clearEntitySelection() -{ - if (m_selectedEntities.empty()) { return; } - m_selectedEntities.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedEntities)); -} - void GameWorldView::pruneDespawnedActors() { - if (m_selectedEntities.empty()) { return; } + const std::vector& selected = m_selection.getSelectedActors(); + if (selected.empty()) { return; } EntityAdmin& admin = m_sim->getAdmin(); std::vector live; - for (entt::entity e : m_selectedEntities) + for (entt::entity e : selected) { if (admin.isValid(e) && admin.hasAll(e) && admin.get(e).hp > 0.0f) @@ -728,18 +710,7 @@ void GameWorldView::pruneDespawnedActors() } } - if (live.size() != m_selectedEntities.size()) - { - m_selectedEntities = std::move(live); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedEntities)); - } -} - -bool GameWorldView::isEntitySelected(entt::entity entity) const -{ - return std::find(m_selectedEntities.begin(), m_selectedEntities.end(), entity) - != m_selectedEntities.end(); + m_selection.setSelectedActors(std::move(live)); } void GameWorldView::stepSpeed(int delta) @@ -1417,7 +1388,7 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter, painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); - for (BuildingId selId : m_selectedBuildingIds) + for (BuildingId selId : m_selection.getSelectedBuildings()) { const std::optional rect = footprintWidgetRect(coordinates, selId); if (!rect.has_value()) { continue; } @@ -1427,14 +1398,13 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter, // A ring around each selected piece of debris, sitting just outside the debris's // rendered circle (radius getTilePx()*0.2, matching drawDebris) (REQ-UI-DEBRIS-CLICK-SELECT). - if (!m_selectedDebris.empty()) + if (!m_selection.getSelectedDebris().empty()) { const qreal outlineRadius = static_cast(coordinates.getTilePx() * 0.2f) + 3.0; for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin())) { - if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), debris.entity) - == m_selectedDebris.end()) { continue; } + if (!m_selection.isDebrisSelected(debris.entity)) { continue; } painter.drawEllipse(coordinates.worldToWidget(debris.position), outlineRadius, outlineRadius); } @@ -1622,7 +1592,7 @@ void GameWorldView::drawStations(QPainter& painter, const WorldCoordinates& coor // colored blue/red by the player/enemy fill it is drawn over. drawBuildingIcon(painter, coordinates, visType, bboxRect, bv.fill); - if (isEntitySelected(e)) + if (m_selection.isActorSelected(e)) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); @@ -1670,7 +1640,7 @@ void GameWorldView::drawShips(QPainter& painter, const WorldCoordinates& coordin painter.setBrush(it->second.fill); painter.drawPolygon(tri); - if (isEntitySelected(e)) + if (m_selection.isActorSelected(e)) { painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2)); painter.setBrush(Qt::NoBrush); @@ -1899,7 +1869,7 @@ void GameWorldView::drawBeams(QPainter& painter, const WorldCoordinates& coordin void GameWorldView::drawSelectedTunnelConnections(QPainter& painter, const WorldCoordinates& coordinates) { - if (m_selectedBuildingIds.empty()) { return; } + if (m_selection.getSelectedBuildings().empty()) { return; } const TunnelTileMap tunnels = collectTunnelTiles(); if (tunnels.empty()) { return; } @@ -1910,7 +1880,7 @@ void GameWorldView::drawSelectedTunnelConnections(QPainter& painter, // (or overlapping runs) is filled exactly once — filling a semi-transparent green // twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT). std::set highlightTiles; - for (const BuildingId id : m_selectedBuildingIds) + for (const BuildingId id : m_selection.getSelectedBuildings()) { std::optional anchor; std::optional type; @@ -2429,130 +2399,84 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) } const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0; - const QVector2D worldPos = coordinates.widgetToWorld(event->pos()); - // Point hit-test precedence: buildings win over actors, which win over debris - // (REQ-UI-SELECTION-CATEGORIES). - std::optional buildingHit = buildingAtTile(tile); - if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); } - - if (buildingHit.has_value()) + // Only a click that hit nothing starts a box drag. Starting one on a hit + // would re-resolve the same object as a 1x1 box on release and undo the + // click: a Ctrl+click would toggle the building off, then straight back on. + if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), ctrl)) { - const BuildingId id = *buildingHit; - // A building selection is exclusive: it clears any field selection — - // actors and debris — because buildings win (REQ-UI-SELECTION-CATEGORIES). - clearEntitySelection(); - clearDebrisSelection(); - if (ctrl) - { - bool found = false; - std::vector newSel; - for (BuildingId sel : m_selectedBuildingIds) - { - if (sel == id) { found = true; } - else { newSel.push_back(sel); } - } - if (!found) { newSel.push_back(id); } - m_selectedBuildingIds = newSel; - } - else - { - m_selectedBuildingIds = { id }; - } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - return; + // selectAtPoint has already cleared the selection unless Ctrl is + // preserving it for an additive drag. + m_boxSelecting = true; + m_boxStartTile = tile; + m_boxCurrentTile = tile; } - - // Selecting a field object (actor or debris) clears any building selection but - // lets actors and debris coexist (REQ-UI-SELECTION-CATEGORIES). - const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos); - if (actorHit != entt::null) - { - if (!m_selectedBuildingIds.empty()) - { - m_selectedBuildingIds.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - } - if (ctrl) - { - // Toggle this actor within the field selection, leaving debris intact - // (REQ-UI-ENTITY-CLICK-SELECT). - bool found = false; - std::vector newSel; - for (entt::entity sel : m_selectedEntities) - { - if (sel == actorHit) { found = true; } - else { newSel.push_back(sel); } - } - if (!found) { newSel.push_back(actorHit); } - m_selectedEntities = newSel; - } - else - { - // A plain click makes this actor the sole selection. - m_selectedEntities = { actorHit }; - clearDebrisSelection(); - } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedEntities)); - return; - } - - if (const entt::entity debrisHit = - debrisAtWorldPos(m_sim->getAdmin(), worldPos); debrisHit != entt::null) - { - if (!m_selectedBuildingIds.empty()) - { - m_selectedBuildingIds.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - } - if (ctrl) - { - // Toggle this debris within the field selection, leaving actors intact - // (REQ-UI-DEBRIS-MULTI-SELECT). - bool found = false; - std::vector newSel; - for (entt::entity sel : m_selectedDebris) - { - if (sel == debrisHit) { found = true; } - else { newSel.push_back(sel); } - } - if (!found) { newSel.push_back(debrisHit); } - m_selectedDebris = newSel; - } - else - { - // A plain click makes this debris the sole selection. - m_selectedDebris = { debrisHit }; - clearEntitySelection(); - } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedDebris)); - return; - } - - // Empty space: a plain click clears the whole selection and starts a box drag; - // Ctrl preserves the current selection for additive box-select. - if (!ctrl) - { - if (!m_selectedBuildingIds.empty()) - { - m_selectedBuildingIds.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - } - clearEntitySelection(); - clearDebrisSelection(); - } - m_boxSelecting = true; - m_boxStartTile = tile; - m_boxCurrentTile = tile; } } +bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive) +{ + // Point hit-test precedence: buildings win over actors, which win over debris + // (REQ-UI-SELECTION-CATEGORIES). What each hit then does to the existing + // selection is the controller's business, not this method's. + const SelectionMode mode = additive ? SelectionMode::Toggle : SelectionMode::Replace; + + std::optional buildingHit = buildingAtTile(tile); + if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); } + if (buildingHit.has_value()) + { + m_selection.selectBuildings({*buildingHit}, mode); + return true; + } + + const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos); + if (actorHit != entt::null) + { + m_selection.selectFieldObjects({actorHit}, {}, mode); + return true; + } + + const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos); + if (debrisHit != entt::null) + { + m_selection.selectFieldObjects({}, {debrisHit}, mode); + return true; + } + + // Empty space: a plain click clears the whole selection, Ctrl preserves it. + if (!additive) { m_selection.clearAll(); } + return false; +} + +void GameWorldView::selectInBox(bool additive) +{ + // Same precedence as a point click, over everything the box covers + // (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT). The only difference is the + // mode: a Ctrl box adds and never deselects, where a Ctrl click toggles. + const SelectionMode mode = additive ? SelectionMode::Add : SelectionMode::Replace; + + const std::vector boxIds = + buildingsInBox(m_boxStartTile, m_boxCurrentTile); + if (!boxIds.empty()) + { + m_selection.selectBuildings(boxIds, mode); + return; + } + + const std::vector boxActors = + actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); + const std::vector boxDebris = + debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); + if (!boxActors.empty() || !boxDebris.empty()) + { + m_selection.selectFieldObjects(boxActors, boxDebris, mode); + return; + } + + // Empty box: a plain (non-additive) drag clears the current selection. + if (!additive) { m_selection.clearAll(); } +} + void GameWorldView::mouseMoveEvent(QMouseEvent* event) { const WorldCoordinates coordinates = getCoordinates(); @@ -2675,91 +2599,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) return; } - const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0; - - if (!boxIds.empty()) - { - // A box covering any building selects buildings; field objects (actors and - // debris) in the box are ignored — buildings win (REQ-UI-MULTI-SELECT). - clearEntitySelection(); - clearDebrisSelection(); - if (!ctrl) - { - m_selectedBuildingIds = boxIds; - } - else - { - for (BuildingId id : boxIds) - { - bool found = false; - for (BuildingId sel : m_selectedBuildingIds) - { - if (sel == id) { found = true; break; } - } - if (!found) { m_selectedBuildingIds.push_back(id); } - } - } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - return; - } - - // No buildings in the box: select the field objects it covers — ships, defence - // stations, and debris together (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT). - const std::vector boxActors = - actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); - const std::vector boxDebris = - debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); - if (!boxActors.empty() || !boxDebris.empty()) - { - if (!m_selectedBuildingIds.empty()) - { - m_selectedBuildingIds.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - } - if (!ctrl) - { - m_selectedEntities = boxActors; - m_selectedDebris = boxDebris; - } - else - { - for (entt::entity e : boxActors) - { - bool found = false; - for (entt::entity sel : m_selectedEntities) - { - if (sel == e) { found = true; break; } - } - if (!found) { m_selectedEntities.push_back(e); } - } - for (entt::entity e : boxDebris) - { - bool found = false; - for (entt::entity sel : m_selectedDebris) - { - if (sel == e) { found = true; break; } - } - if (!found) { m_selectedDebris.push_back(e); } - } - } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedEntities)); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedDebris)); - return; - } - - // Empty box: a plain (non-additive) drag clears the current selection. - if (!ctrl) - { - m_selectedBuildingIds.clear(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedBuildingIds)); - clearEntitySelection(); - clearDebrisSelection(); - } + selectInBox((event->modifiers() & Qt::ControlModifier) != 0); } } @@ -2978,9 +2818,7 @@ void GameWorldView::resetForNewGame() m_deconstructHoverBuildingId = std::nullopt; EventManager::getInstance()->sendEventImmediately( std::make_shared(false)); - m_selectedBuildingIds.clear(); - clearEntitySelection(); - clearDebrisSelection(); + m_selection.clearAll(); m_copiedConfig = std::nullopt; m_copyConfigFlashes.clear(); m_boxSelecting = false; diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 5085d09..2397e21 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -52,6 +52,7 @@ #include "EntitySelectionChangedEvent.h" #include "GameConfig.h" #include "Rotation.h" +#include "SelectionController.h" #include "Tick.h" #include "TickDriver.h" #include "TunnelCompletion.h" @@ -247,21 +248,18 @@ private: void placeBlueprintAtTile(QPoint center); std::optional entityPosition(entt::entity entity) const; - // Clears the debris selection, emitting an empty DebrisSelectionChangedEvent when - // it was non-empty (REQ-UI-DEBRIS-CLICK-SELECT). Used when another selection - // category takes over. - void clearDebrisSelection(); - // Drops despawned or fully-collected debris from the selection and re-emits - // when it changed (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame(). + // Drops despawned or fully-collected debris from the selection + // (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame(). void pruneDespawnedDebris(); - // Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was - // non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over. - void clearEntitySelection(); - // Drops despawned or dead actors from the selection and re-emits when it changed - // (REQ-UI-ENTITY-CLICK-SELECT). Called each frame from onFrame(). + // Drops despawned or dead actors from the selection (REQ-UI-ENTITY-CLICK-SELECT). + // Called each frame from onFrame(). void pruneDespawnedActors(); - // True if the given actor is part of the current actor selection. - bool isEntitySelected(entt::entity entity) const; + // Resolves a click into the selection it should produce, applying the category + // precedence of REQ-UI-SELECTION-CATEGORIES; the controller owns what that then + // does to the existing selection. Returns false when the click hit nothing, + // which is the only case that goes on to start a box drag. + bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive); + void selectInBox(bool additive); void stepSpeed(int delta); void placeAtTile(QPoint tile); @@ -410,9 +408,10 @@ private: std::optional m_deconstructHoverBuildingId; bool m_debugDraw; - std::vector m_selectedBuildingIds; - std::vector m_selectedEntities; - std::vector m_selectedDebris; + // Owns the selection across all three categories and the rules for moving + // between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change + // events. This widget only resolves what was hit. + SelectionController m_selection; bool m_boxSelecting; QPoint m_boxStartTile; QPoint m_boxCurrentTile;