diff --git a/src/balancing/ArenaView.cpp b/src/balancing/ArenaView.cpp index b9128eb..36443ca 100644 --- a/src/balancing/ArenaView.cpp +++ b/src/balancing/ArenaView.cpp @@ -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 selection; + if (m_selectedEntity.has_value()) + { + selection.push_back(*m_selectedEntity); + } EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_selectedEntity)); + std::make_shared(selection)); } QOpenGLWidget::mousePressEvent(event); diff --git a/src/balancing/InspectWindow.cpp b/src/balancing/InspectWindow.cpp index 234bd87..24121d6 100644 --- a/src/balancing/InspectWindow.cpp +++ b/src/balancing/InspectWindow.cpp @@ -252,9 +252,10 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status) void InspectWindow::handleEvent(std::shared_ptr 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; diff --git a/src/lib/eventsystem/event/EntitySelectedEvent.h b/src/lib/eventsystem/event/EntitySelectedEvent.h index d54a9cd..9c5c5a1 100644 --- a/src/lib/eventsystem/event/EntitySelectedEvent.h +++ b/src/lib/eventsystem/event/EntitySelectedEvent.h @@ -1,21 +1,25 @@ #ifndef ENTITY_SELECTED_EVENT_H #define ENTITY_SELECTED_EVENT_H -#include +#include #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 entity) - : entity(entity) + explicit EntitySelectedEvent(std::vector entities) + : entities(std::move(entities)) { } - const std::optional entity; + const std::vector entities; }; #endif // ENTITY_SELECTED_EVENT_H diff --git a/src/lib/sim/EntityHitTest.cpp b/src/lib/sim/EntityHitTest.cpp index 948dbdf..77c7445 100644 --- a/src/lib/sim/EntityHitTest.cpp +++ b/src/lib/sim/EntityHitTest.cpp @@ -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 scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint ti }); return result; } + +std::vector 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 result; + + // Stations: included when any occupied body cell lies in the box. + admin.forEach( + [&](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( + [&](entt::entity entity, const ShipIdentityComponent& /*id*/, + const PositionComponent& pos, const HealthComponent& h) + { + if (h.hp <= 0.0f) { return; } + const int tileX = static_cast(std::floor(pos.value.x())); + const int tileY = static_cast(std::floor(pos.value.y())); + if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY) + { + result.push_back(entity); + } + }); + + return result; +} diff --git a/src/lib/sim/EntityHitTest.h b/src/lib/sim/EntityHitTest.h index 5ca4f3f..ca473a5 100644 --- a/src/lib/sim/EntityHitTest.h +++ b/src/lib/sim/EntityHitTest.h @@ -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 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 actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB); diff --git a/src/test/ScrapTest.cpp b/src/test/ScrapTest.cpp index 1fdb44b..35209bc 100644 --- a/src/test/ScrapTest.cpp +++ b/src/test/ScrapTest.cpp @@ -1,5 +1,6 @@ #include "catch.hpp" +#include #include #include @@ -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 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 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)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 55b7c65..a39a9cf 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -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(m_selectedEntities)); +} + +void GameWorldView::pruneDespawnedActors() +{ + if (m_selectedEntities.empty()) { return; } + + EntityAdmin& admin = m_sim->getAdmin(); + std::vector live; + for (entt::entity e : m_selectedEntities) + { + if (admin.isValid(e) && admin.hasAll(e) + && admin.get(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(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 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(m_selectedBuildingIds)); - m_selectedEntity = hitEntity; - EventManager::getInstance()->sendEventImmediately( - std::make_shared(hitEntity)); - } - else - { - if (m_selectedEntity.has_value()) + if (ctrl) { - m_selectedEntity = std::nullopt; - EventManager::getInstance()->sendEventImmediately( - std::make_shared(std::nullopt)); - } - - std::optional 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 newSel; + for (BuildingId sel : m_selectedBuildingIds) { - bool found = false; - std::vector 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(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(m_selectedBuildingIds)); - } - if (event->modifiers() & Qt::ControlModifier) - { - bool found = false; - std::vector 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(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(m_selectedBuildingIds)); - clearScrapSelection(); - } - m_boxSelecting = true; - m_boxStartTile = tile; - m_boxCurrentTile = tile; + m_selectedBuildingIds = { id }; } + EventManager::getInstance()->sendEventImmediately( + std::make_shared(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(m_selectedBuildingIds)); + } + if (ctrl) + { + // Toggle this actor within the field selection, leaving scrap intact + // (REQ-UI-ENTITY-CLICK-SELECT). + bool found = false; + std::vector 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(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(m_selectedBuildingIds)); + } + if (ctrl) + { + // Toggle this pile within the field selection, leaving actors intact + // (REQ-UI-SCRAP-MULTI-SELECT). + bool found = false; + std::vector 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(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(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 boxActors = + actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); const std::vector 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(m_selectedEntities)); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedScrap)); return; @@ -2241,6 +2320,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) m_selectedBuildingIds.clear(); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_selectedBuildingIds)); + clearEntitySelection(); clearScrapSelection(); } } @@ -2455,6 +2535,8 @@ void GameWorldView::resetForNewGame() EventManager::getInstance()->sendEventImmediately( std::make_shared(false)); m_selectedBuildingIds.clear(); + clearEntitySelection(); + clearScrapSelection(); m_copiedConfig = std::nullopt; m_copyConfigFlashes.clear(); m_boxSelecting = false; diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index f869138..2a44caf 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -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 m_selectedBuildingIds; - std::optional m_selectedEntity; + std::vector m_selectedEntities; std::vector m_selectedScrap; bool m_boxSelecting; QPoint m_boxStartTile; diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index f4c8693..7c9d448 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -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& 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 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(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(entity)) { buildEntityShip(entity); } - else if (admin.hasAll(entity)) + else if (admin.isValid(entity) && admin.hasAll(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 keys; + std::map counts; + std::map labels; + int shown = 0; + + for (entt::entity entity : m_selectedEntities) + { + if (!admin.isValid(entity)) { continue; } + const bool isEnemy = admin.hasAll(entity) + && admin.get(entity).isEnemy; + + QString key; + QString label; + if (admin.hasAll(entity)) + { + const std::string& id = admin.get(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(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(entity)) { return; } const HealthComponent& health = admin.get(entity); - if (health.hp <= 0.0f) - { - clearEntityDisplay(); - return; - } + if (health.hp <= 0.0f) { return; } if (admin.hasAll(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 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() diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectedBuildingPanel.h index 2717c0a..164f251 100644 --- a/src/ui/SelectedBuildingPanel.h +++ b/src/ui/SelectedBuildingPanel.h @@ -80,7 +80,6 @@ private: void buildEmpty(); void buildSingle(BuildingId id); void buildMulti(const std::vector& 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 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 m_selectedEntities; ShipStatsPanel* m_entityStatsPanel; QLabel* m_entityTitleLabel; QLabel* m_stationStatsLabel; + QLabel* m_entitySummaryLabel; std::vector 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(); };