extract SelectionController

This commit is contained in:
2026-08-05 20:18:08 +02:00
parent d5ab44b9bf
commit 202f583067
7 changed files with 631 additions and 269 deletions

View File

@@ -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
)

View File

@@ -0,0 +1,165 @@
#include "SelectionController.h"
#include <algorithm>
#include <memory>
#include "DebrisSelectionChangedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "SelectionChangedEvent.h"
namespace
{
template <typename T>
bool contains(const std::vector<T>& 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 <typename T>
void combine(std::vector<T>& selection, const std::vector<T>& hits, SelectionMode mode)
{
for (const T& hit : hits)
{
const typename std::vector<T>::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<BuildingId>& SelectionController::getSelectedBuildings() const
{
return m_buildings;
}
const std::vector<entt::entity>& SelectionController::getSelectedActors() const
{
return m_actors;
}
const std::vector<entt::entity>& 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<BuildingId>& 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<entt::entity>& actors,
const std::vector<entt::entity>& 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<entt::entity> actors)
{
if (actors == m_actors) { return; }
m_actors = std::move(actors);
publishActors();
}
void SelectionController::setSelectedDebris(std::vector<entt::entity> 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<SelectionChangedEvent>(m_buildings));
}
void SelectionController::publishActors() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_actors));
}
void SelectionController::publishDebris() const
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebrisSelectionChangedEvent>(m_debris));
}

View File

@@ -0,0 +1,75 @@
#pragma once
#include <vector>
#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<BuildingId>& getSelectedBuildings() const;
const std::vector<entt::entity>& getSelectedActors() const;
const std::vector<entt::entity>& 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<BuildingId>& 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<entt::entity>& actors,
const std::vector<entt::entity>& 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<entt::entity> actors);
void setSelectedDebris(std::vector<entt::entity> 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<BuildingId> m_buildings;
std::vector<entt::entity> m_actors;
std::vector<entt::entity> m_debris;
};

View File

@@ -14,6 +14,7 @@ add_files(
TunnelCompletionTest.cpp
WorldCoordinatesTest.cpp
WorldCameraTest.cpp
SelectionControllerTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -0,0 +1,282 @@
#include "catch.hpp"
#include <memory>
#include <vector>
#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<SelectionChangedEvent,
EntitySelectionChangedEvent,
DebrisSelectionChangedEvent>
{
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<const SelectionChangedEvent> /*event*/) override
{
++buildingEvents;
}
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> /*event*/) override
{
++actorEvents;
}
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> /*event*/) override
{
++debrisEvents;
}
};
static entt::entity makeEntity(int index)
{
return static_cast<entt::entity>(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>{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<entt::entity>{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<entt::entity>{makeEntity(1)});
REQUIRE(controller.getSelectedDebris() == std::vector<entt::entity>{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<entt::entity>{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>{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>{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>{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>{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<entt::entity>{makeEntity(1)});
REQUIRE(controller.getSelectedDebris() == std::vector<entt::entity>{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<entt::entity>{makeEntity(1)});
}

View File

@@ -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<QVector2D> GameWorldView::entityPosition(entt::entity entity) cons
return m_sim->getAdmin().get<PositionComponent>(entity).value;
}
void GameWorldView::clearDebrisSelection()
{
if (m_selectedDebris.empty()) { return; }
m_selectedDebris.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebrisSelectionChangedEvent>(m_selectedDebris));
}
void GameWorldView::pruneDespawnedDebris()
{
if (m_selectedDebris.empty()) { return; }
const std::vector<entt::entity>& selected = m_selection.getSelectedDebris();
if (selected.empty()) { return; }
// Keeps the debris that still exist, in the order the simulation reports them.
std::vector<entt::entity> 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<DebrisSelectionChangedEvent>(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<EntitySelectionChangedEvent>(m_selectedEntities));
}
void GameWorldView::pruneDespawnedActors()
{
if (m_selectedEntities.empty()) { return; }
const std::vector<entt::entity>& selected = m_selection.getSelectedActors();
if (selected.empty()) { return; }
EntityAdmin& admin = m_sim->getAdmin();
std::vector<entt::entity> live;
for (entt::entity e : m_selectedEntities)
for (entt::entity e : selected)
{
if (admin.isValid(e) && admin.hasAll<HealthComponent>(e)
&& admin.get<HealthComponent>(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<EntitySelectionChangedEvent>(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<QRectF> 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<qreal>(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<QPoint, QPointCompare> highlightTiles;
for (const BuildingId id : m_selectedBuildingIds)
for (const BuildingId id : m_selection.getSelectedBuildings())
{
std::optional<QPoint> anchor;
std::optional<BuildingType> type;
@@ -2429,128 +2399,82 @@ 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<BuildingId> 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<BuildingId> 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<SelectionChangedEvent>(m_selectedBuildingIds));
return;
}
// 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<SelectionChangedEvent>(m_selectedBuildingIds));
}
if (ctrl)
{
// Toggle this actor within the field selection, leaving debris intact
// (REQ-UI-ENTITY-CLICK-SELECT).
bool found = false;
std::vector<entt::entity> 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<EntitySelectionChangedEvent>(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<SelectionChangedEvent>(m_selectedBuildingIds));
}
if (ctrl)
{
// Toggle this debris within the field selection, leaving actors intact
// (REQ-UI-DEBRIS-MULTI-SELECT).
bool found = false;
std::vector<entt::entity> 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<DebrisSelectionChangedEvent>(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<SelectionChangedEvent>(m_selectedBuildingIds));
}
clearEntitySelection();
clearDebrisSelection();
}
// selectAtPoint has already cleared the selection unless Ctrl is
// preserving it for an additive drag.
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<BuildingId> 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<BuildingId> boxIds =
buildingsInBox(m_boxStartTile, m_boxCurrentTile);
if (!boxIds.empty())
{
m_selection.selectBuildings(boxIds, mode);
return;
}
const std::vector<entt::entity> boxActors =
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
const std::vector<entt::entity> 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)
@@ -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<SelectionChangedEvent>(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<entt::entity> boxActors =
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
const std::vector<entt::entity> 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<SelectionChangedEvent>(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<EntitySelectionChangedEvent>(m_selectedEntities));
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebrisSelectionChangedEvent>(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<SelectionChangedEvent>(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<DeconstructModeChangedEvent>(false));
m_selectedBuildingIds.clear();
clearEntitySelection();
clearDebrisSelection();
m_selection.clearAll();
m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear();
m_boxSelecting = false;

View File

@@ -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<QVector2D> 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<BuildingId> m_deconstructHoverBuildingId;
bool m_debugDraw;
std::vector<BuildingId> m_selectedBuildingIds;
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> 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;