extract SelectionController
This commit is contained in:
@@ -16,6 +16,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
165
src/lib/core/SelectionController.cpp
Normal file
165
src/lib/core/SelectionController.cpp
Normal 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));
|
||||||
|
}
|
||||||
75
src/lib/core/SelectionController.h
Normal file
75
src/lib/core/SelectionController.h
Normal 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;
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ add_files(
|
|||||||
TunnelCompletionTest.cpp
|
TunnelCompletionTest.cpp
|
||||||
WorldCoordinatesTest.cpp
|
WorldCoordinatesTest.cpp
|
||||||
WorldCameraTest.cpp
|
WorldCameraTest.cpp
|
||||||
|
SelectionControllerTest.cpp
|
||||||
BuildingTest.cpp
|
BuildingTest.cpp
|
||||||
BuildingConfigTest.cpp
|
BuildingConfigTest.cpp
|
||||||
ShipTest.cpp
|
ShipTest.cpp
|
||||||
|
|||||||
282
src/test/SelectionControllerTest.cpp
Normal file
282
src/test/SelectionControllerTest.cpp
Normal 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)});
|
||||||
|
}
|
||||||
@@ -46,7 +46,6 @@
|
|||||||
#include "ReplayRecorder.h"
|
#include "ReplayRecorder.h"
|
||||||
#include "DeconstructModeChangedEvent.h"
|
#include "DeconstructModeChangedEvent.h"
|
||||||
#include "EntityHitTest.h"
|
#include "EntityHitTest.h"
|
||||||
#include "EntitySelectionChangedEvent.h"
|
|
||||||
#include "EventManager.h"
|
#include "EventManager.h"
|
||||||
#include "FacingComponent.h"
|
#include "FacingComponent.h"
|
||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
@@ -58,7 +57,6 @@
|
|||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
#include "RepairBehavior.h"
|
#include "RepairBehavior.h"
|
||||||
#include "SalvageScrapBehavior.h"
|
#include "SalvageScrapBehavior.h"
|
||||||
#include "DebrisSelectionChangedEvent.h"
|
|
||||||
#include "DebrisSystem.h"
|
#include "DebrisSystem.h"
|
||||||
#include "SelectionChangedEvent.h"
|
#include "SelectionChangedEvent.h"
|
||||||
#include "SensorRangeComponent.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;
|
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()
|
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;
|
std::vector<entt::entity> live;
|
||||||
for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
|
for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
|
||||||
{
|
{
|
||||||
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
|
if (std::find(selected.begin(), selected.end(), info.entity) != selected.end())
|
||||||
!= m_selectedDebris.end())
|
|
||||||
{
|
{
|
||||||
live.push_back(info.entity);
|
live.push_back(info.entity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (live.size() != m_selectedDebris.size())
|
if (live.size() != selected.size())
|
||||||
{
|
{
|
||||||
m_selectedDebris = std::move(live);
|
m_selection.setSelectedDebris(std::move(live));
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<DebrisSelectionChangedEvent>(m_selectedDebris));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameWorldView::clearEntitySelection()
|
|
||||||
{
|
|
||||||
if (m_selectedEntities.empty()) { return; }
|
|
||||||
m_selectedEntities.clear();
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
|
|
||||||
}
|
|
||||||
|
|
||||||
void GameWorldView::pruneDespawnedActors()
|
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();
|
EntityAdmin& admin = m_sim->getAdmin();
|
||||||
std::vector<entt::entity> live;
|
std::vector<entt::entity> live;
|
||||||
for (entt::entity e : m_selectedEntities)
|
for (entt::entity e : selected)
|
||||||
{
|
{
|
||||||
if (admin.isValid(e) && admin.hasAll<HealthComponent>(e)
|
if (admin.isValid(e) && admin.hasAll<HealthComponent>(e)
|
||||||
&& admin.get<HealthComponent>(e).hp > 0.0f)
|
&& admin.get<HealthComponent>(e).hp > 0.0f)
|
||||||
@@ -728,18 +710,7 @@ void GameWorldView::pruneDespawnedActors()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (live.size() != m_selectedEntities.size())
|
m_selection.setSelectedActors(std::move(live));
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameWorldView::stepSpeed(int delta)
|
void GameWorldView::stepSpeed(int delta)
|
||||||
@@ -1417,7 +1388,7 @@ void GameWorldView::drawSelectionHighlights(QPainter& painter,
|
|||||||
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
|
|
||||||
for (BuildingId selId : m_selectedBuildingIds)
|
for (BuildingId selId : m_selection.getSelectedBuildings())
|
||||||
{
|
{
|
||||||
const std::optional<QRectF> rect = footprintWidgetRect(coordinates, selId);
|
const std::optional<QRectF> rect = footprintWidgetRect(coordinates, selId);
|
||||||
if (!rect.has_value()) { continue; }
|
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
|
// 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).
|
// 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 =
|
const qreal outlineRadius =
|
||||||
static_cast<qreal>(coordinates.getTilePx() * 0.2f) + 3.0;
|
static_cast<qreal>(coordinates.getTilePx() * 0.2f) + 3.0;
|
||||||
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
|
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
|
||||||
{
|
{
|
||||||
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), debris.entity)
|
if (!m_selection.isDebrisSelected(debris.entity)) { continue; }
|
||||||
== m_selectedDebris.end()) { continue; }
|
|
||||||
painter.drawEllipse(coordinates.worldToWidget(debris.position),
|
painter.drawEllipse(coordinates.worldToWidget(debris.position),
|
||||||
outlineRadius, outlineRadius);
|
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.
|
// colored blue/red by the player/enemy fill it is drawn over.
|
||||||
drawBuildingIcon(painter, coordinates, visType, bboxRect, bv.fill);
|
drawBuildingIcon(painter, coordinates, visType, bboxRect, bv.fill);
|
||||||
|
|
||||||
if (isEntitySelected(e))
|
if (m_selection.isActorSelected(e))
|
||||||
{
|
{
|
||||||
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
@@ -1670,7 +1640,7 @@ void GameWorldView::drawShips(QPainter& painter, const WorldCoordinates& coordin
|
|||||||
painter.setBrush(it->second.fill);
|
painter.setBrush(it->second.fill);
|
||||||
painter.drawPolygon(tri);
|
painter.drawPolygon(tri);
|
||||||
|
|
||||||
if (isEntitySelected(e))
|
if (m_selection.isActorSelected(e))
|
||||||
{
|
{
|
||||||
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
@@ -1899,7 +1869,7 @@ void GameWorldView::drawBeams(QPainter& painter, const WorldCoordinates& coordin
|
|||||||
void GameWorldView::drawSelectedTunnelConnections(QPainter& painter,
|
void GameWorldView::drawSelectedTunnelConnections(QPainter& painter,
|
||||||
const WorldCoordinates& coordinates)
|
const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
if (m_selectedBuildingIds.empty()) { return; }
|
if (m_selection.getSelectedBuildings().empty()) { return; }
|
||||||
|
|
||||||
const TunnelTileMap tunnels = collectTunnelTiles();
|
const TunnelTileMap tunnels = collectTunnelTiles();
|
||||||
if (tunnels.empty()) { return; }
|
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
|
// (or overlapping runs) is filled exactly once — filling a semi-transparent green
|
||||||
// twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
|
// twice would darken it (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
|
||||||
std::set<QPoint, QPointCompare> highlightTiles;
|
std::set<QPoint, QPointCompare> highlightTiles;
|
||||||
for (const BuildingId id : m_selectedBuildingIds)
|
for (const BuildingId id : m_selection.getSelectedBuildings())
|
||||||
{
|
{
|
||||||
std::optional<QPoint> anchor;
|
std::optional<QPoint> anchor;
|
||||||
std::optional<BuildingType> type;
|
std::optional<BuildingType> type;
|
||||||
@@ -2429,128 +2399,82 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
|
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
|
// Only a click that hit nothing starts a box drag. Starting one on a hit
|
||||||
// (REQ-UI-SELECTION-CATEGORIES).
|
// would re-resolve the same object as a 1x1 box on release and undo the
|
||||||
std::optional<BuildingId> buildingHit = buildingAtTile(tile);
|
// click: a Ctrl+click would toggle the building off, then straight back on.
|
||||||
if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
|
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), ctrl))
|
||||||
|
|
||||||
if (buildingHit.has_value())
|
|
||||||
{
|
{
|
||||||
const BuildingId id = *buildingHit;
|
// selectAtPoint has already cleared the selection unless Ctrl is
|
||||||
// A building selection is exclusive: it clears any field selection —
|
// preserving it for an additive drag.
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
m_boxSelecting = true;
|
m_boxSelecting = true;
|
||||||
m_boxStartTile = tile;
|
m_boxStartTile = tile;
|
||||||
m_boxCurrentTile = 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)
|
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
|
||||||
@@ -2675,91 +2599,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
|
selectInBox((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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2978,9 +2818,7 @@ void GameWorldView::resetForNewGame()
|
|||||||
m_deconstructHoverBuildingId = std::nullopt;
|
m_deconstructHoverBuildingId = std::nullopt;
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
std::make_shared<DeconstructModeChangedEvent>(false));
|
std::make_shared<DeconstructModeChangedEvent>(false));
|
||||||
m_selectedBuildingIds.clear();
|
m_selection.clearAll();
|
||||||
clearEntitySelection();
|
|
||||||
clearDebrisSelection();
|
|
||||||
m_copiedConfig = std::nullopt;
|
m_copiedConfig = std::nullopt;
|
||||||
m_copyConfigFlashes.clear();
|
m_copyConfigFlashes.clear();
|
||||||
m_boxSelecting = false;
|
m_boxSelecting = false;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
#include "EntitySelectionChangedEvent.h"
|
#include "EntitySelectionChangedEvent.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
|
#include "SelectionController.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "TickDriver.h"
|
#include "TickDriver.h"
|
||||||
#include "TunnelCompletion.h"
|
#include "TunnelCompletion.h"
|
||||||
@@ -247,21 +248,18 @@ private:
|
|||||||
void placeBlueprintAtTile(QPoint center);
|
void placeBlueprintAtTile(QPoint center);
|
||||||
|
|
||||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||||
// Clears the debris selection, emitting an empty DebrisSelectionChangedEvent when
|
// Drops despawned or fully-collected debris from the selection
|
||||||
// it was non-empty (REQ-UI-DEBRIS-CLICK-SELECT). Used when another selection
|
// (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
|
||||||
// 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().
|
|
||||||
void pruneDespawnedDebris();
|
void pruneDespawnedDebris();
|
||||||
// Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was
|
// Drops despawned or dead actors from the selection (REQ-UI-ENTITY-CLICK-SELECT).
|
||||||
// non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over.
|
// Called each frame from onFrame().
|
||||||
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().
|
|
||||||
void pruneDespawnedActors();
|
void pruneDespawnedActors();
|
||||||
// True if the given actor is part of the current actor selection.
|
// Resolves a click into the selection it should produce, applying the category
|
||||||
bool isEntitySelected(entt::entity entity) const;
|
// 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 stepSpeed(int delta);
|
||||||
void placeAtTile(QPoint tile);
|
void placeAtTile(QPoint tile);
|
||||||
|
|
||||||
@@ -410,9 +408,10 @@ private:
|
|||||||
std::optional<BuildingId> m_deconstructHoverBuildingId;
|
std::optional<BuildingId> m_deconstructHoverBuildingId;
|
||||||
bool m_debugDraw;
|
bool m_debugDraw;
|
||||||
|
|
||||||
std::vector<BuildingId> m_selectedBuildingIds;
|
// Owns the selection across all three categories and the rules for moving
|
||||||
std::vector<entt::entity> m_selectedEntities;
|
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
|
||||||
std::vector<entt::entity> m_selectedDebris;
|
// events. This widget only resolves what was hit.
|
||||||
|
SelectionController m_selection;
|
||||||
bool m_boxSelecting;
|
bool m_boxSelecting;
|
||||||
QPoint m_boxStartTile;
|
QPoint m_boxStartTile;
|
||||||
QPoint m_boxCurrentTile;
|
QPoint m_boxCurrentTile;
|
||||||
|
|||||||
Reference in New Issue
Block a user