extract SelectionController

The selection rules were written out twice - once for point clicks and once for
box drags - and the two copies had already drifted. Unlike the earlier seams on
this branch this is a real de-duplication, not a relocation.

SelectionController owns all three categories and the rules for moving between
them: buildings win over field objects, so selecting a building clears actors and
debris and selecting either of those clears buildings, while actors and debris
coexist. It also publishes the change events, so callers never emit by hand.

A point click and a box drag now differ only in the SelectionMode they pass and
in how many hits they pass. That names the difference that was previously implicit
in two separate loops: Ctrl+click toggles, so dragging back over a selected
building would deselect it, whereas Ctrl+box adds and never deselects.

Hit-testing stays in GameWorldView, which is what keeps the controller free of any
simulation dependency and therefore testable - including the published events,
via a spy handler. That coverage is the point: these rules had none, and the two
copies were the kind of thing that drifts silently.

One behaviour difference, deliberate. Clearing the building selection was guarded
by "only if non-empty" in three of the four places and unguarded in the fourth
(the empty-box drag), which emitted a redundant SelectionChangedEvent with an
empty list. The unified rule is the guarded one. Subscribers re-read from
Simulation on every event, so dropping a redundant no-op refresh cannot change
what any of them display.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
2026-08-05 14:24:52 +02:00
parent 71c07531f0
commit 1eca61e934
7 changed files with 623 additions and 267 deletions

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,17 @@ 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.
void selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
void selectInBox(bool additive);
void stepSpeed(int delta);
void placeAtTile(QPoint tile);
@@ -410,9 +407,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;