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:
2026-07-20 20:23:09 +02:00
parent 3ebaa48cc5
commit 4044846d6a
10 changed files with 445 additions and 163 deletions

View File

@@ -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()