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

@@ -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,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<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;
// 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<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();
}
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)
{
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<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;