Multi-select ships/stations, mixable with scrap
Reshape game-world selection into two categories: buildings (exclusive, win all ties) and field objects (ships, defence stations, scrap) that coexist. Ships/stations are now multi-selectable via Ctrl+click and box-drag and can be selected together with scrap; the Selected panel shows a single-actor stats panel or a multi-actor summary plus the scrap total. - Widen EntitySelectedEvent to std::vector<entt::entity> - Add actorsInBox() ECS query (+ Catch2 test) - GameWorldView: vector actor selection, building>actor>scrap hit-test, actor Ctrl-toggle/box-select, membership highlights, per-frame prune - SelectedBuildingPanel: buildFieldSelection composition, buildEntitySummary - ArenaView/InspectWindow: adapt to vector payload (single-select) Implements REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION and the updated entity/scrap selection requirements. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
This commit is contained in:
@@ -259,8 +259,14 @@ void ArenaView::mousePressEvent(QMouseEvent* event)
|
||||
m_selectedEntity = std::nullopt;
|
||||
}
|
||||
|
||||
// The arena is strictly single-select; emit a vector of size 0 or 1.
|
||||
std::vector<entt::entity> selection;
|
||||
if (m_selectedEntity.has_value())
|
||||
{
|
||||
selection.push_back(*m_selectedEntity);
|
||||
}
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<EntitySelectedEvent>(m_selectedEntity));
|
||||
std::make_shared<EntitySelectedEvent>(selection));
|
||||
}
|
||||
|
||||
QOpenGLWidget::mousePressEvent(event);
|
||||
|
||||
@@ -252,9 +252,10 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
|
||||
|
||||
void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectedEvent> event)
|
||||
{
|
||||
if (event->entity.has_value())
|
||||
if (!event->entities.empty())
|
||||
{
|
||||
m_selectedEntity = event->entity;
|
||||
// The arena is single-select, so only the first entity is inspected.
|
||||
m_selectedEntity = event->entities.front();
|
||||
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
entt::entity entity = *m_selectedEntity;
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
#ifndef ENTITY_SELECTED_EVENT_H
|
||||
#define ENTITY_SELECTED_EVENT_H
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
|
||||
#include "Event.h"
|
||||
|
||||
// The set of currently selected ships and/or defence stations. An empty list means
|
||||
// no actor is selected. Actors share the "field" selection category with scrap piles
|
||||
// (REQ-UI-SELECTION-CATEGORIES): they can be selected together, but never together with
|
||||
// buildings.
|
||||
class EntitySelectedEvent : public Event
|
||||
{
|
||||
public:
|
||||
explicit EntitySelectedEvent(std::optional<entt::entity> entity)
|
||||
: entity(entity)
|
||||
explicit EntitySelectedEvent(std::vector<entt::entity> entities)
|
||||
: entities(std::move(entities))
|
||||
{
|
||||
}
|
||||
|
||||
const std::optional<entt::entity> entity;
|
||||
const std::vector<entt::entity> entities;
|
||||
};
|
||||
|
||||
#endif // ENTITY_SELECTED_EVENT_H
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "EntityAdmin.h"
|
||||
#include "PositionComponent.h"
|
||||
#include "ScrapDataComponent.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "HealthComponent.h"
|
||||
|
||||
@@ -101,3 +102,46 @@ std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint ti
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB)
|
||||
{
|
||||
const int minX = std::min(tileA.x(), tileB.x());
|
||||
const int maxX = std::max(tileA.x(), tileB.x());
|
||||
const int minY = std::min(tileA.y(), tileB.y());
|
||||
const int maxY = std::max(tileA.y(), tileB.y());
|
||||
|
||||
std::vector<entt::entity> result;
|
||||
|
||||
// Stations: included when any occupied body cell lies in the box.
|
||||
admin.forEach<StationBodyComponent, HealthComponent>(
|
||||
[&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h)
|
||||
{
|
||||
if (h.hp <= 0.0f) { return; }
|
||||
for (const QPoint& cell : sb.bodyCells)
|
||||
{
|
||||
if (cell.x() >= minX && cell.x() <= maxX
|
||||
&& cell.y() >= minY && cell.y() <= maxY)
|
||||
{
|
||||
result.push_back(entity);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ships: included when the floored position tile lies in the box. Requiring
|
||||
// ShipIdentityComponent excludes the HQ proxy and any station bodies.
|
||||
admin.forEach<ShipIdentityComponent, PositionComponent, HealthComponent>(
|
||||
[&](entt::entity entity, const ShipIdentityComponent& /*id*/,
|
||||
const PositionComponent& pos, const HealthComponent& h)
|
||||
{
|
||||
if (h.hp <= 0.0f) { return; }
|
||||
const int tileX = static_cast<int>(std::floor(pos.value.x()));
|
||||
const int tileY = static_cast<int>(std::floor(pos.value.y()));
|
||||
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
|
||||
{
|
||||
result.push_back(entity);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -19,3 +19,10 @@ entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
|
||||
// Returns every scrap pile whose position falls within the inclusive tile rectangle
|
||||
// spanned by tileA and tileB, in any corner order (REQ-UI-SCRAP-MULTI-SELECT).
|
||||
std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);
|
||||
|
||||
// Returns every living actor (ship or defence station, player or enemy) that falls
|
||||
// within the inclusive tile rectangle spanned by tileA and tileB, in any corner order
|
||||
// (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT). A ship is included when its floored
|
||||
// position tile lies in the box; a station is included when any of its body cells does.
|
||||
// Dead actors (hp <= 0) and the HQ proxy are excluded.
|
||||
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <QSize>
|
||||
#include <QVector2D>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -226,3 +227,45 @@ TEST_CASE("scrapInBox returns exactly the piles inside the tile rectangle", "[sc
|
||||
REQUIRE(contains(hit, inB));
|
||||
REQUIRE_FALSE(contains(hit, outX));
|
||||
}
|
||||
|
||||
TEST_CASE("actorsInBox returns living ships and stations, excluding scrap and dead actors",
|
||||
"[actor]")
|
||||
{
|
||||
EntityAdmin admin;
|
||||
|
||||
// Two living ships inside the box: one player, one enemy.
|
||||
const entt::entity playerShip = admin.spawnShip(
|
||||
QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||
"fighter", false); // tile (1,2)
|
||||
const entt::entity enemyShip = admin.spawnShip(
|
||||
QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||
"raider", true); // tile (4,5)
|
||||
|
||||
// A dead ship inside the box is excluded.
|
||||
const entt::entity deadShip = admin.spawnShip(
|
||||
QVector2D(3.0f, 3.0f), 0.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||
"fighter", false);
|
||||
|
||||
// A ship outside the box is excluded.
|
||||
const entt::entity outsideShip = admin.spawnShip(
|
||||
QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
|
||||
"fighter", false);
|
||||
|
||||
// A station is included when any body cell lies inside the box.
|
||||
const std::vector<QPoint> stationCells{ QPoint(2, 2), QPoint(3, 2) };
|
||||
const entt::entity station = admin.spawnStation(
|
||||
QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true);
|
||||
|
||||
// Scrap and the HQ proxy are never actors.
|
||||
admin.spawnScrap(QVector2D(1.0f, 1.0f), 5, Tick(1000));
|
||||
admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f);
|
||||
|
||||
const std::vector<entt::entity> hit = actorsInBox(admin, QPoint(5, 5), QPoint(0, 0));
|
||||
|
||||
REQUIRE(hit.size() == 3);
|
||||
REQUIRE(contains(hit, playerShip));
|
||||
REQUIRE(contains(hit, enemyShip));
|
||||
REQUIRE(contains(hit, station));
|
||||
REQUIRE_FALSE(contains(hit, deadShip));
|
||||
REQUIRE_FALSE(contains(hit, outsideShip));
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ void GameWorldView::onFrame()
|
||||
// Drop selected scrap piles that were collected or despawned this frame, so the
|
||||
// panel stops counting them and the selection empties out (REQ-UI-SCRAP-CLICK-SELECT).
|
||||
pruneDespawnedScrap();
|
||||
pruneDespawnedActors();
|
||||
|
||||
// Expire copy/paste flashes. Lifetime is wall-clock (the frame delta), so the
|
||||
// flash plays for a fixed real duration regardless of game speed, including
|
||||
@@ -725,6 +726,43 @@ void GameWorldView::pruneDespawnedScrap()
|
||||
}
|
||||
}
|
||||
|
||||
void GameWorldView::clearEntitySelection()
|
||||
{
|
||||
if (m_selectedEntities.empty()) { return; }
|
||||
m_selectedEntities.clear();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<EntitySelectedEvent>(m_selectedEntities));
|
||||
}
|
||||
|
||||
void GameWorldView::pruneDespawnedActors()
|
||||
{
|
||||
if (m_selectedEntities.empty()) { return; }
|
||||
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
std::vector<entt::entity> live;
|
||||
for (entt::entity e : m_selectedEntities)
|
||||
{
|
||||
if (admin.isValid(e) && admin.hasAll<HealthComponent>(e)
|
||||
&& admin.get<HealthComponent>(e).hp > 0.0f)
|
||||
{
|
||||
live.push_back(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (live.size() != m_selectedEntities.size())
|
||||
{
|
||||
m_selectedEntities = std::move(live);
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<EntitySelectedEvent>(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)
|
||||
{
|
||||
const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
||||
@@ -1296,7 +1334,7 @@ void GameWorldView::drawStations(QPainter& painter)
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawRect(bboxRect);
|
||||
|
||||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||||
if (isEntitySelected(e))
|
||||
{
|
||||
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
@@ -1343,7 +1381,7 @@ void GameWorldView::drawShips(QPainter& painter)
|
||||
painter.setBrush(it->second.fill);
|
||||
painter.drawPolygon(tri);
|
||||
|
||||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||||
if (isEntitySelected(e))
|
||||
{
|
||||
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
@@ -2012,102 +2050,128 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
}
|
||||
}
|
||||
|
||||
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
|
||||
const QVector2D worldPos = widgetToWorld(event->pos());
|
||||
const entt::entity hitEntity = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
||||
|
||||
if (hitEntity != entt::null)
|
||||
// Point hit-test precedence: buildings win over actors, which win over scrap
|
||||
// (REQ-UI-SELECTION-CATEGORIES).
|
||||
std::optional<BuildingId> buildingHit = buildingAtTile(tile);
|
||||
if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
|
||||
|
||||
if (buildingHit.has_value())
|
||||
{
|
||||
// Actors (ship/station) win over scrap and buildings (REQ-UI-SCRAP-CLICK-SELECT).
|
||||
const BuildingId id = *buildingHit;
|
||||
// A building selection is exclusive: it clears any field selection —
|
||||
// actors and scrap — because buildings win (REQ-UI-SELECTION-CATEGORIES).
|
||||
clearEntitySelection();
|
||||
clearScrapSelection();
|
||||
m_selectedBuildingIds.clear();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||||
m_selectedEntity = hitEntity;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<EntitySelectedEvent>(hitEntity));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (m_selectedEntity.has_value())
|
||||
if (ctrl)
|
||||
{
|
||||
m_selectedEntity = std::nullopt;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<EntitySelectedEvent>(std::nullopt));
|
||||
}
|
||||
|
||||
std::optional<BuildingId> hit = buildingAtTile(tile);
|
||||
if (!hit.has_value())
|
||||
{
|
||||
hit = siteAtTile(tile);
|
||||
}
|
||||
if (hit.has_value())
|
||||
{
|
||||
const BuildingId id = *hit;
|
||||
// A building/construction site outranks scrap (REQ-UI-SCRAP-CLICK-SELECT).
|
||||
clearScrapSelection();
|
||||
if (event->modifiers() & Qt::ControlModifier)
|
||||
bool found = false;
|
||||
std::vector<BuildingId> newSel;
|
||||
for (BuildingId sel : m_selectedBuildingIds)
|
||||
{
|
||||
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;
|
||||
if (sel == id) { found = true; }
|
||||
else { newSel.push_back(sel); }
|
||||
}
|
||||
else
|
||||
{
|
||||
m_selectedBuildingIds = { id };
|
||||
}
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||||
}
|
||||
else if (const entt::entity scrapHit =
|
||||
scrapAtWorldPos(m_sim->getAdmin(), worldPos); scrapHit != entt::null)
|
||||
{
|
||||
// Scrap forms its own selection category; picking it clears any
|
||||
// building selection (REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
|
||||
if (!m_selectedBuildingIds.empty())
|
||||
{
|
||||
m_selectedBuildingIds.clear();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||||
}
|
||||
if (event->modifiers() & Qt::ControlModifier)
|
||||
{
|
||||
bool found = false;
|
||||
std::vector<entt::entity> newSel;
|
||||
for (entt::entity sel : m_selectedScrap)
|
||||
{
|
||||
if (sel == scrapHit) { found = true; }
|
||||
else { newSel.push_back(sel); }
|
||||
}
|
||||
if (!found) { newSel.push_back(scrapHit); }
|
||||
m_selectedScrap = newSel;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_selectedScrap = { scrapHit };
|
||||
}
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
|
||||
if (!found) { newSel.push_back(id); }
|
||||
m_selectedBuildingIds = newSel;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!(event->modifiers() & Qt::ControlModifier))
|
||||
{
|
||||
m_selectedBuildingIds.clear();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||||
clearScrapSelection();
|
||||
}
|
||||
m_boxSelecting = true;
|
||||
m_boxStartTile = tile;
|
||||
m_boxCurrentTile = tile;
|
||||
m_selectedBuildingIds = { id };
|
||||
}
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||||
return;
|
||||
}
|
||||
|
||||
// Selecting a field object (actor or scrap) clears any building selection but
|
||||
// lets actors and scrap 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 scrap 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 };
|
||||
clearScrapSelection();
|
||||
}
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<EntitySelectedEvent>(m_selectedEntities));
|
||||
return;
|
||||
}
|
||||
|
||||
if (const entt::entity scrapHit =
|
||||
scrapAtWorldPos(m_sim->getAdmin(), worldPos); scrapHit != entt::null)
|
||||
{
|
||||
if (!m_selectedBuildingIds.empty())
|
||||
{
|
||||
m_selectedBuildingIds.clear();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||||
}
|
||||
if (ctrl)
|
||||
{
|
||||
// Toggle this pile within the field selection, leaving actors intact
|
||||
// (REQ-UI-SCRAP-MULTI-SELECT).
|
||||
bool found = false;
|
||||
std::vector<entt::entity> newSel;
|
||||
for (entt::entity sel : m_selectedScrap)
|
||||
{
|
||||
if (sel == scrapHit) { found = true; }
|
||||
else { newSel.push_back(sel); }
|
||||
}
|
||||
if (!found) { newSel.push_back(scrapHit); }
|
||||
m_selectedScrap = newSel;
|
||||
}
|
||||
else
|
||||
{
|
||||
// A plain click makes this pile the sole selection.
|
||||
m_selectedScrap = { scrapHit };
|
||||
clearEntitySelection();
|
||||
}
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
|
||||
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();
|
||||
clearScrapSelection();
|
||||
}
|
||||
m_boxSelecting = true;
|
||||
m_boxStartTile = tile;
|
||||
m_boxCurrentTile = tile;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2178,8 +2242,9 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
|
||||
if (!boxIds.empty())
|
||||
{
|
||||
// A box covering any building selects buildings; scrap in the box is
|
||||
// ignored (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
|
||||
// A box covering any building selects buildings; field objects (actors and
|
||||
// scrap) in the box are ignored — buildings win (REQ-UI-MULTI-SELECT).
|
||||
clearEntitySelection();
|
||||
clearScrapSelection();
|
||||
if (!ctrl)
|
||||
{
|
||||
@@ -2202,11 +2267,13 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
return;
|
||||
}
|
||||
|
||||
// No buildings in the box: a scrap-only box selects the scrap it covers
|
||||
// (REQ-UI-SCRAP-MULTI-SELECT).
|
||||
// No buildings in the box: select the field objects it covers — ships, defence
|
||||
// stations, and scrap together (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
|
||||
const std::vector<entt::entity> boxActors =
|
||||
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
|
||||
const std::vector<entt::entity> boxScrap =
|
||||
scrapInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
|
||||
if (!boxScrap.empty())
|
||||
if (!boxActors.empty() || !boxScrap.empty())
|
||||
{
|
||||
if (!m_selectedBuildingIds.empty())
|
||||
{
|
||||
@@ -2216,10 +2283,20 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
}
|
||||
if (!ctrl)
|
||||
{
|
||||
m_selectedScrap = boxScrap;
|
||||
m_selectedEntities = boxActors;
|
||||
m_selectedScrap = boxScrap;
|
||||
}
|
||||
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 : boxScrap)
|
||||
{
|
||||
bool found = false;
|
||||
@@ -2230,6 +2307,8 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
if (!found) { m_selectedScrap.push_back(e); }
|
||||
}
|
||||
}
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<EntitySelectedEvent>(m_selectedEntities));
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
|
||||
return;
|
||||
@@ -2241,6 +2320,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
|
||||
m_selectedBuildingIds.clear();
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
|
||||
clearEntitySelection();
|
||||
clearScrapSelection();
|
||||
}
|
||||
}
|
||||
@@ -2455,6 +2535,8 @@ void GameWorldView::resetForNewGame()
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<DemolishModeChangedEvent>(false));
|
||||
m_selectedBuildingIds.clear();
|
||||
clearEntitySelection();
|
||||
clearScrapSelection();
|
||||
m_copiedConfig = std::nullopt;
|
||||
m_copyConfigFlashes.clear();
|
||||
m_boxSelecting = false;
|
||||
|
||||
@@ -191,6 +191,14 @@ private:
|
||||
// Drops despawned or fully-collected piles from the scrap selection and re-emits
|
||||
// when it changed (REQ-UI-SCRAP-CLICK-SELECT). Called each frame from onFrame().
|
||||
void pruneDespawnedScrap();
|
||||
// Clears the actor selection, emitting an empty EntitySelectedEvent 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().
|
||||
void pruneDespawnedActors();
|
||||
// True if the given actor is part of the current actor selection.
|
||||
bool isEntitySelected(entt::entity entity) const;
|
||||
void stepSpeed(int delta);
|
||||
void placeAtTile(QPoint tile);
|
||||
|
||||
@@ -272,7 +280,7 @@ private:
|
||||
bool m_debugDraw;
|
||||
|
||||
std::vector<BuildingId> m_selectedBuildingIds;
|
||||
std::optional<entt::entity> m_selectedEntity;
|
||||
std::vector<entt::entity> m_selectedEntities;
|
||||
std::vector<entt::entity> m_selectedScrap;
|
||||
bool m_boxSelecting;
|
||||
QPoint m_boxStartTile;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "BeltSystem.h"
|
||||
#include "Command.h"
|
||||
#include "CommandRequestedEvent.h"
|
||||
#include "DisplayName.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "EntitySelectedEvent.h"
|
||||
@@ -196,6 +197,11 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
|
||||
m_layout->addWidget(m_stationStatsLabel);
|
||||
m_stationStatsLabel->hide();
|
||||
|
||||
m_entitySummaryLabel = new QLabel(this);
|
||||
m_entitySummaryLabel->setWordWrap(true);
|
||||
m_layout->addWidget(m_entitySummaryLabel);
|
||||
m_entitySummaryLabel->hide();
|
||||
|
||||
m_scrapLabel = new QLabel(this);
|
||||
m_layout->addWidget(m_scrapLabel);
|
||||
m_scrapLabel->hide();
|
||||
@@ -215,8 +221,9 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& id
|
||||
m_selectedBuildingIds = ids;
|
||||
if (!ids.empty())
|
||||
{
|
||||
// A building selection is exclusive: it supersedes any field selection —
|
||||
// actors and scrap (REQ-UI-SELECTION-CATEGORIES).
|
||||
clearEntityDisplay();
|
||||
// A building selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT).
|
||||
m_selectedScrap.clear();
|
||||
m_scrapLabel->hide();
|
||||
}
|
||||
@@ -266,6 +273,7 @@ void SelectedBuildingPanel::buildEmpty()
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
m_entitySummaryLabel->hide();
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::buildSingle(BuildingId id)
|
||||
@@ -658,16 +666,16 @@ void SelectedBuildingPanel::handleEvent(
|
||||
|
||||
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
|
||||
{
|
||||
if (!m_selectedScrap.empty())
|
||||
{
|
||||
// The total shrinks live as piles are collected or despawn (REQ-UI-SCRAP-PANEL).
|
||||
refreshScrapTotal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (m_selectedEntity.has_value())
|
||||
if (!m_selectedEntities.empty() || !m_selectedScrap.empty())
|
||||
{
|
||||
// Field selection: keep the live single-actor stats current, and refresh the
|
||||
// scrap total, which shrinks live as piles are collected or despawn
|
||||
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-SCRAP-PANEL).
|
||||
refreshEntityStats();
|
||||
if (!m_selectedScrap.empty())
|
||||
{
|
||||
refreshScrapTotal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -908,37 +916,134 @@ void SelectedBuildingPanel::onClearBelt()
|
||||
|
||||
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectedEvent> event)
|
||||
{
|
||||
if (event->entity.has_value())
|
||||
m_selectedEntities = event->entities;
|
||||
if (!m_selectedEntities.empty())
|
||||
{
|
||||
m_selectedEntity = event->entity;
|
||||
// A field selection supersedes any building selection (REQ-UI-SELECTION-CATEGORIES).
|
||||
m_selectedBuildingIds.clear();
|
||||
// An entity selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT).
|
||||
m_selectedScrap.clear();
|
||||
}
|
||||
buildFieldSelection();
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::buildFieldSelection()
|
||||
{
|
||||
if (m_selectedEntities.empty() && m_selectedScrap.empty())
|
||||
{
|
||||
// Nothing in the field category. Fall back to empty unless buildings own the panel.
|
||||
clearEntityDisplay();
|
||||
m_scrapLabel->hide();
|
||||
clearContent();
|
||||
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
entt::entity entity = *m_selectedEntity;
|
||||
|
||||
if (!admin.isValid(entity))
|
||||
if (m_selectedBuildingIds.empty())
|
||||
{
|
||||
clearEntityDisplay();
|
||||
return;
|
||||
buildEmpty();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
||||
// A field selection owns the panel: drop any building content.
|
||||
clearContent();
|
||||
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
|
||||
// Actor section: single-actor stats panel, multi-actor summary, or nothing.
|
||||
if (m_selectedEntities.empty())
|
||||
{
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
m_entitySummaryLabel->hide();
|
||||
}
|
||||
else if (m_selectedEntities.size() == 1)
|
||||
{
|
||||
m_entitySummaryLabel->hide();
|
||||
const entt::entity entity = m_selectedEntities.front();
|
||||
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
|
||||
{
|
||||
buildEntityShip(entity);
|
||||
}
|
||||
else if (admin.hasAll<StationBodyComponent>(entity))
|
||||
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
|
||||
{
|
||||
buildEntityStation(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
clearEntityDisplay();
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
buildEntitySummary();
|
||||
}
|
||||
|
||||
// Scrap section, appended below the actor section (REQ-UI-SCRAP-PANEL).
|
||||
if (!m_selectedScrap.empty())
|
||||
{
|
||||
refreshScrapTotal();
|
||||
m_scrapLabel->show();
|
||||
}
|
||||
else
|
||||
{
|
||||
m_scrapLabel->hide();
|
||||
}
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::buildEntitySummary()
|
||||
{
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
|
||||
// Group actors by faction + kind + ship schematic, preserving first-seen order
|
||||
// (REQ-UI-FIELD-MULTI-SELECTION).
|
||||
std::vector<QString> keys;
|
||||
std::map<QString, int> counts;
|
||||
std::map<QString, QString> labels;
|
||||
int shown = 0;
|
||||
|
||||
for (entt::entity entity : m_selectedEntities)
|
||||
{
|
||||
if (!admin.isValid(entity)) { continue; }
|
||||
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
|
||||
&& admin.get<FactionComponent>(entity).isEnemy;
|
||||
|
||||
QString key;
|
||||
QString label;
|
||||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
||||
{
|
||||
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
|
||||
const QString name = QString::fromStdString(toDisplayName(id));
|
||||
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
|
||||
+ QString::fromStdString(id);
|
||||
label = isEnemy ? tr("Enemy %1").arg(name) : name;
|
||||
}
|
||||
else if (admin.hasAll<StationBodyComponent>(entity))
|
||||
{
|
||||
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
|
||||
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (counts.find(key) == counts.end())
|
||||
{
|
||||
keys.push_back(key);
|
||||
labels[key] = label;
|
||||
}
|
||||
counts[key] += 1;
|
||||
++shown;
|
||||
}
|
||||
|
||||
QString text = tr("%1 selected").arg(shown);
|
||||
for (const QString& key : keys)
|
||||
{
|
||||
text += tr("\n%1 ×%2").arg(labels[key]).arg(counts[key]);
|
||||
}
|
||||
m_entitySummaryLabel->setText(text);
|
||||
m_entitySummaryLabel->show();
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
|
||||
@@ -1016,23 +1121,17 @@ void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
|
||||
|
||||
void SelectedBuildingPanel::refreshEntityStats()
|
||||
{
|
||||
if (!m_selectedEntity.has_value()) { return; }
|
||||
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
|
||||
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
|
||||
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
|
||||
if (m_selectedEntities.size() != 1) { return; }
|
||||
|
||||
EntityAdmin& admin = m_sim->getAdmin();
|
||||
entt::entity entity = *m_selectedEntity;
|
||||
|
||||
if (!admin.isValid(entity))
|
||||
{
|
||||
clearEntityDisplay();
|
||||
return;
|
||||
}
|
||||
const entt::entity entity = m_selectedEntities.front();
|
||||
|
||||
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
|
||||
const HealthComponent& health = admin.get<HealthComponent>(entity);
|
||||
if (health.hp <= 0.0f)
|
||||
{
|
||||
clearEntityDisplay();
|
||||
return;
|
||||
}
|
||||
if (health.hp <= 0.0f) { return; }
|
||||
|
||||
if (admin.hasAll<ShipIdentityComponent>(entity))
|
||||
{
|
||||
@@ -1049,10 +1148,11 @@ void SelectedBuildingPanel::refreshEntityStats()
|
||||
|
||||
void SelectedBuildingPanel::clearEntityDisplay()
|
||||
{
|
||||
m_selectedEntity = std::nullopt;
|
||||
m_selectedEntities.clear();
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
m_entitySummaryLabel->hide();
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
||||
@@ -1066,31 +1166,11 @@ void SelectedBuildingPanel::handleEvent(
|
||||
m_selectedScrap = event->scrap;
|
||||
if (!m_selectedScrap.empty())
|
||||
{
|
||||
// Scrap is its own selection category, mutually exclusive with buildings and
|
||||
// entities (REQ-UI-SCRAP-CLICK-SELECT).
|
||||
// Scrap is a field object: it supersedes any building selection but coexists
|
||||
// with actors (REQ-UI-SELECTION-CATEGORIES).
|
||||
m_selectedBuildingIds.clear();
|
||||
clearContent();
|
||||
clearEntityDisplay();
|
||||
buildScrap();
|
||||
}
|
||||
else
|
||||
{
|
||||
m_scrapLabel->hide();
|
||||
if (m_selectedBuildingIds.empty() && !m_selectedEntity.has_value())
|
||||
{
|
||||
buildEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::buildScrap()
|
||||
{
|
||||
clearContent();
|
||||
m_entityTitleLabel->hide();
|
||||
m_entityStatsPanel->hide();
|
||||
m_stationStatsLabel->hide();
|
||||
refreshScrapTotal();
|
||||
m_scrapLabel->show();
|
||||
buildFieldSelection();
|
||||
}
|
||||
|
||||
void SelectedBuildingPanel::refreshScrapTotal()
|
||||
|
||||
@@ -80,7 +80,6 @@ private:
|
||||
void buildEmpty();
|
||||
void buildSingle(BuildingId id);
|
||||
void buildMulti(const std::vector<BuildingId>& ids);
|
||||
void buildScrap();
|
||||
void refreshScrapTotal();
|
||||
void refreshBuffers(const Building* b);
|
||||
void refreshSiteProgress(const ConstructionSite* s);
|
||||
@@ -115,16 +114,24 @@ private:
|
||||
std::string m_currentRecipeId;
|
||||
|
||||
bool m_debugDraw = false;
|
||||
std::optional<entt::entity> m_selectedEntity;
|
||||
// The selected ships/defence stations. Shares the "field" selection category with
|
||||
// scrap (m_selectedScrap): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
|
||||
std::vector<entt::entity> m_selectedEntities;
|
||||
ShipStatsPanel* m_entityStatsPanel;
|
||||
QLabel* m_entityTitleLabel;
|
||||
QLabel* m_stationStatsLabel;
|
||||
QLabel* m_entitySummaryLabel;
|
||||
|
||||
std::vector<entt::entity> m_selectedScrap;
|
||||
QLabel* m_scrapLabel;
|
||||
|
||||
// Renders the combined field selection (actors + scrap): a single-actor stats panel
|
||||
// or a multi-actor summary, plus the scrap total when scrap is also selected
|
||||
// (REQ-UI-FIELD-MULTI-SELECTION).
|
||||
void buildFieldSelection();
|
||||
void buildEntityShip(entt::entity entity);
|
||||
void buildEntityStation(entt::entity entity);
|
||||
void buildEntitySummary();
|
||||
void refreshEntityStats();
|
||||
void clearEntityDisplay();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user