add FieldSelectionPanel for extracting the ships/stations/debris selection

This commit is contained in:
2026-08-04 18:23:28 +02:00
parent 64c344c3a3
commit bd344e4fbe
5 changed files with 540 additions and 398 deletions

View File

@@ -9,6 +9,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
@@ -30,6 +31,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp

View File

@@ -0,0 +1,403 @@
#include "FieldSelectionPanel.h"
#include <algorithm>
#include <map>
#include <string>
#include <QFont>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
#include "DebrisSystem.h"
#include "DisplayName.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "ThreatCostCalculator.h"
#include "WeaponComponent.h"
FieldSelectionPanel::FieldSelectionPanel(Simulation* sim,
const GameConfig* config,
QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
{
// Zero margins and the same spacing as the enclosing SelectedBuildingPanel layout, so
// nesting the field widgets in this panel leaves their geometry unchanged.
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(4);
m_layout->setAlignment(Qt::AlignTop);
m_entityTitleLabel = new QLabel(this);
QFont titleFont = m_entityTitleLabel->font();
titleFont.setBold(true);
m_entityTitleLabel->setFont(titleFont);
m_layout->addWidget(m_entityTitleLabel);
m_entityTitleLabel->hide();
m_entityStatsPanel = new ShipStatsPanel(config, this);
m_layout->addWidget(m_entityStatsPanel);
m_entityStatsPanel->hide();
m_stationStatsLabel = new QLabel(this);
m_stationStatsLabel->setWordWrap(true);
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();
hide();
registerForEvents();
}
FieldSelectionPanel::~FieldSelectionPanel()
{
unregisterForEvents();
}
void FieldSelectionPanel::setSelectedEntities(const std::vector<entt::entity>& entities)
{
m_selectedEntities = entities;
rebuild();
}
void FieldSelectionPanel::setSelectedDebris(const std::vector<entt::entity>& debris)
{
m_selectedDebris = debris;
rebuild();
}
void FieldSelectionPanel::clearSelection()
{
m_selectedEntities.clear();
m_selectedDebris.clear();
rebuild();
}
bool FieldSelectionPanel::hasSelection() const
{
return !m_selectedEntities.empty() || !m_selectedDebris.empty();
}
void FieldSelectionPanel::hideAllWidgets()
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
}
void FieldSelectionPanel::rebuild()
{
if (!hasSelection())
{
// Nothing in the field category: take no space, leaving the panel to whatever
// the building category shows (REQ-UI-SELECTION-CATEGORIES).
hideAllWidgets();
hide();
return;
}
show();
EntityAdmin& admin = m_sim->getAdmin();
// A full single-object stats panel is shown only for a lone field object: one actor
// with no debris, or one piece of debris with no actors. As soon as the selection holds
// more than one object (multiple actors, multiple debris, or actors plus debris), the
// panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
const entt::entity entity = m_selectedEntities.front();
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
{
buildEntityShip(entity);
}
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
else
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
}
return;
}
if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
// Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like
// the ship/station stats panels (REQ-UI-DEBRIS-PANEL).
m_entitySummaryLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
buildDebrisSingle();
return;
}
// More than one field object: a compact count summary. buildEntitySummary() appends the
// "Debris x N" and "Scrap x N" lines when debris is part of the selection.
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_scrapLabel->hide();
buildEntitySummary();
}
void FieldSelectionPanel::refreshDisplay()
{
if (!hasSelection()) { return; }
// Keep the live values current: the single-actor stats panel, the single-debris stats
// panel (whose Scrap row shrinks as it is collected), or the count summary (whose Scrap
// line shrinks likewise) — matching the layout chosen by rebuild()
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
refreshEntityStats();
}
else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
buildDebrisSingle();
}
else
{
buildEntitySummary();
}
}
void FieldSelectionPanel::buildDebrisSingle()
{
// "Debris" heading + a single "Scrap" stat row for the piece's remaining amount,
// mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL).
m_entityTitleLabel->setText(tr("Debris"));
m_entityTitleLabel->show();
m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal()));
m_scrapLabel->show();
}
void FieldSelectionPanel::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;
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;
}
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
// multi-selection). No total-count header, consistent with the building panel. When
// debris is part of the selection, a "Debris x <count>" line followed by a
// "Scrap x <total>" line are appended into the same label so the line spacing is
// uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_selectedDebris.empty())
{
lines << tr("Debris x %1").arg(static_cast<int>(m_selectedDebris.size()));
lines << scrapTotalText();
}
m_entitySummaryLabel->setText(lines.join('\n'));
m_entitySummaryLabel->show();
}
void FieldSelectionPanel::buildEntityShip(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
const HealthComponent& health = admin.get<HealthComponent>(entity);
m_entityTitleLabel->setText(tr("Ship: %1")
.arg(QString::fromStdString(identity.schematicId)));
m_entityTitleLabel->show();
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
const ShipDef* schematicDef =
m_config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
const double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, schematicDef->id,
schematicDef->defaultModules);
m_entityStatsPanel->setThreatCost(threat);
}
m_entityStatsPanel->show();
m_stationStatsLabel->hide();
}
void FieldSelectionPanel::buildEntityStation(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const HealthComponent& health = admin.get<HealthComponent>(entity);
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
m_entityTitleLabel->setText(isEnemy
? tr("Enemy Defence Station")
: tr("Player Defence Station"));
m_entityTitleLabel->show();
float totalDps = 0.0f;
float maxRange = 0.0f;
bool hasWeapons = false;
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
{
if (owner.owner != entity) { return; }
hasWeapons = true;
totalDps += w.damage * w.fireRateHz;
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
});
QString statsText = tr("HP: %1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f));
if (hasWeapons)
{
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
}
m_stationStatsLabel->setText(statsText);
m_stationStatsLabel->show();
m_entityStatsPanel->hide();
}
void FieldSelectionPanel::refreshEntityStats()
{
// 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();
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) { return; }
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
}
int FieldSelectionPanel::selectedDebrisScrapTotal() const
{
// Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL).
int total = 0;
for (const DebrisInfo& info : m_sim->getDebrisSystem().getAllDebrisInfo())
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end())
{
total += info.amount;
}
}
return total;
}
QString FieldSelectionPanel::scrapTotalText() const
{
return tr("Scrap x %1").arg(selectedDebrisScrapTotal());
}
void FieldSelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{
refreshDisplay();
}
void FieldSelectionPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{
// Player commands are applied by a queued drain, not synchronously. When the game is
// paused no tick advances, so TickAdvancedEvent never fires; refresh here too.
refreshDisplay();
}
void FieldSelectionPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
{
m_debugDraw = event->active;
m_entityStatsPanel->setDebugDrawEnabled(event->active);
}

View File

@@ -0,0 +1,91 @@
#pragma once
#include <vector>
#include <QString>
#include <QWidget>
#include "entt/entity/entity.hpp"
#include "DebugDrawToggledEvent.h"
#include "EventHandler.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h"
struct GameConfig;
class Simulation;
class ShipStatsPanel;
class QLabel;
class QVBoxLayout;
// Renders the "field" selection category — ships, defence stations and debris — as either
// a single-object stats panel (ship, station, or debris) or a compact multi-object count
// summary (REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
//
// The panel owns its own selection state and its own widgets, and nothing else. Which of
// the two selection categories owns the side panel is arbitrated by the parent
// SelectedBuildingPanel: it feeds this panel through setSelectedEntities() /
// setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides
// itself whenever its selection is empty, so an inactive field category takes no space.
class FieldSelectionPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
DebugDrawToggledEvent>
{
Q_OBJECT
public:
FieldSelectionPanel(Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr);
~FieldSelectionPanel() override;
// Replaces the selected actors (ships and defence stations); debris is left alone,
// the two coexist within the field category (REQ-UI-SELECTION-CATEGORIES).
void setSelectedEntities(const std::vector<entt::entity>& entities);
// Replaces the selected debris; the selected actors are left alone.
void setSelectedDebris(const std::vector<entt::entity>& debris);
// Drops the whole field selection — used when the building category takes over.
void clearSelection();
// True while the field category has anything selected, i.e. while this panel owns
// the side panel's content.
bool hasSelection() const;
private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
// Picks the layout for the current selection and shows/hides this panel accordingly.
void rebuild();
// Keeps the live values of the layout chosen by rebuild() current.
void refreshDisplay();
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void buildDebrisSingle();
void refreshEntityStats();
void hideAllWidgets();
// Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL).
int selectedDebrisScrapTotal() const;
// "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION).
QString scrapTotalText() const;
Simulation* m_sim;
const GameConfig* m_config;
bool m_debugDraw = false;
// The selected ships/defence stations. Shares the "field" selection category with
// debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedDebris;
QVBoxLayout* m_layout;
QLabel* m_entityTitleLabel;
ShipStatsPanel* m_entityStatsPanel;
QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
// Shows the debris "Scrap" stat row (single selection) — the scrap total for the
// multi-object summary lives in m_entitySummaryLabel instead.
QLabel* m_scrapLabel;
};

View File

@@ -9,26 +9,14 @@
#include <QLabel> #include <QLabel>
#include <QListWidget> #include <QListWidget>
#include <QPushButton> #include <QPushButton>
#include <QStringList>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Command.h" #include "Command.h"
#include "CommandRequestedEvent.h" #include "CommandRequestedEvent.h"
#include "DisplayName.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "EntitySelectionChangedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "FactionComponent.h" #include "FieldSelectionPanel.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
#include "ThreatCostCalculator.h"
#include "StationBodyComponent.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
@@ -40,10 +28,8 @@
#include "RecipeSelectionDialog.h" #include "RecipeSelectionDialog.h"
#include "RecipeSelectionRequestedEvent.h" #include "RecipeSelectionRequestedEvent.h"
#include "Rotation.h" #include "Rotation.h"
#include "DebrisSystem.h"
#include "ShipLayoutPreview.h" #include "ShipLayoutPreview.h"
#include "Simulation.h" #include "Simulation.h"
#include "WeaponComponent.h"
namespace namespace
{ {
@@ -182,30 +168,10 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
connect(m_filterBList, &QListWidget::itemChanged, connect(m_filterBList, &QListWidget::itemChanged,
this, &SelectedBuildingPanel::onSplitterFilterChanged); this, &SelectedBuildingPanel::onSplitterFilterChanged);
m_entityTitleLabel = new QLabel(this); // The field selection renders below the building content and hides itself while
QFont titleFont = m_entityTitleLabel->font(); // nothing field-side is selected, so it costs no space then.
titleFont.setBold(true); m_fieldSelectionPanel = new FieldSelectionPanel(sim, config, this);
m_entityTitleLabel->setFont(titleFont); m_layout->addWidget(m_fieldSelectionPanel);
m_layout->addWidget(m_entityTitleLabel);
m_entityTitleLabel->hide();
m_entityStatsPanel = new ShipStatsPanel(config, this);
m_layout->addWidget(m_entityStatsPanel);
m_entityStatsPanel->hide();
m_stationStatsLabel = new QLabel(this);
m_stationStatsLabel->setWordWrap(true);
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();
buildEmpty(); buildEmpty();
@@ -224,13 +190,21 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& id
{ {
// A building selection is exclusive: it supersedes any field selection — // A building selection is exclusive: it supersedes any field selection —
// actors and scrap (REQ-UI-SELECTION-CATEGORIES). // actors and scrap (REQ-UI-SELECTION-CATEGORIES).
clearEntityDisplay(); m_fieldSelectionPanel->clearSelection();
m_selectedDebris.clear();
m_scrapLabel->hide();
} }
rebuild(); rebuild();
} }
void SelectedBuildingPanel::yieldToFieldSelection()
{
// The mirror image of onSelectionChanged(): a field selection — actors, debris, or
// both — supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). An empty
// field selection changes nothing here: the building content, if any, keeps the panel.
if (!m_fieldSelectionPanel->hasSelection()) { return; }
m_selectedBuildingIds.clear();
buildEmpty();
}
void SelectedBuildingPanel::rebuild() void SelectedBuildingPanel::rebuild()
{ {
if (m_selectedBuildingIds.empty()) if (m_selectedBuildingIds.empty())
@@ -259,22 +233,14 @@ void SelectedBuildingPanel::hideAllWidgets()
m_filterBLabel->hide(); m_filterBLabel->hide();
m_filterBList->hide(); m_filterBList->hide();
m_buffersLabel->hide(); m_buffersLabel->hide();
m_scrapLabel->hide();
}
void SelectedBuildingPanel::clearContent()
{
m_singleBuildingId = std::nullopt;
hideAllWidgets();
} }
void SelectedBuildingPanel::buildEmpty() void SelectedBuildingPanel::buildEmpty()
{ {
clearContent(); // Shows nothing for the building category — either because nothing is selected or
m_entityTitleLabel->hide(); // because the field category has taken the panel over.
m_entityStatsPanel->hide(); m_singleBuildingId = std::nullopt;
m_stationStatsLabel->hide(); hideAllWidgets();
m_entitySummaryLabel->hide();
} }
void SelectedBuildingPanel::buildSingle(BuildingId id) void SelectedBuildingPanel::buildSingle(BuildingId id)
@@ -646,27 +612,9 @@ void SelectedBuildingPanel::handleEvent(
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
{ {
if (!m_selectedEntities.empty() || !m_selectedDebris.empty()) // Only a single selected building has live content to refresh. While the field
{ // category owns the panel there is none: yieldToFieldSelection() has cleared it, so
// Field selection. Keep the live values current: the single-actor stats panel, // this returns immediately and the field panel refreshes itself off the same events.
// the single-debris stats panel (whose Scrap row shrinks as it is collected), or
// the count summary (whose Scrap line shrinks likewise) — matching the layout
// chosen by buildFieldSelection() (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
refreshEntityStats();
}
else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
buildDebrisSingle();
}
else
{
buildEntitySummary();
}
return;
}
if (!m_singleBuildingId.has_value()) { return; } if (!m_singleBuildingId.has_value()) { return; }
const Building* b = m_sim->getBuildings().findBuilding(*m_singleBuildingId); const Building* b = m_sim->getBuildings().findBuilding(*m_singleBuildingId);
if (b) if (b)
@@ -904,259 +852,8 @@ void SelectedBuildingPanel::onClearBelt()
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
{ {
m_selectedEntities = event->entities; m_fieldSelectionPanel->setSelectedEntities(event->entities);
if (!m_selectedEntities.empty()) yieldToFieldSelection();
{
// A field selection supersedes any building selection (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear();
}
buildFieldSelection();
}
void SelectedBuildingPanel::buildFieldSelection()
{
if (m_selectedEntities.empty() && m_selectedDebris.empty())
{
// Nothing in the field category. Fall back to empty unless buildings own the panel.
clearEntityDisplay();
m_scrapLabel->hide();
if (m_selectedBuildingIds.empty())
{
buildEmpty();
}
return;
}
// A field selection owns the panel: drop any building content.
clearContent();
EntityAdmin& admin = m_sim->getAdmin();
// A full single-object stats panel is shown only for a lone field object: one actor
// with no debris, or one piece of debris with no actors. As soon as the selection holds
// more than one object (multiple actors, multiple debris, or actors plus debris), the
// panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
const entt::entity entity = m_selectedEntities.front();
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
{
buildEntityShip(entity);
}
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
else
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
}
return;
}
if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
// Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like
// the ship/station stats panels (REQ-UI-DEBRIS-PANEL).
m_entitySummaryLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
buildDebrisSingle();
return;
}
// More than one field object: a compact count summary. buildEntitySummary() appends the
// "Debris x N" and "Scrap x N" lines when debris is part of the selection.
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_scrapLabel->hide();
buildEntitySummary();
}
void SelectedBuildingPanel::buildDebrisSingle()
{
// "Debris" heading + a single "Scrap" stat row for the piece's remaining amount,
// mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL).
m_entityTitleLabel->setText(tr("Debris"));
m_entityTitleLabel->show();
m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal()));
m_scrapLabel->show();
}
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;
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;
}
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
// multi-selection). No total-count header, consistent with the building panel. When
// debris is part of the selection, a "Debris x <count>" line followed by a
// "Scrap x <total>" line are appended into the same label so the line spacing is
// uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_selectedDebris.empty())
{
lines << tr("Debris x %1").arg(static_cast<int>(m_selectedDebris.size()));
lines << scrapTotalText();
}
m_entitySummaryLabel->setText(lines.join('\n'));
m_entitySummaryLabel->show();
}
void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
const HealthComponent& health = admin.get<HealthComponent>(entity);
m_entityTitleLabel->setText(tr("Ship: %1")
.arg(QString::fromStdString(identity.schematicId)));
m_entityTitleLabel->show();
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
const ShipDef* schematicDef =
m_config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
const double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, schematicDef->id,
schematicDef->defaultModules);
m_entityStatsPanel->setThreatCost(threat);
}
m_entityStatsPanel->show();
m_stationStatsLabel->hide();
}
void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const HealthComponent& health = admin.get<HealthComponent>(entity);
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
m_entityTitleLabel->setText(isEnemy
? tr("Enemy Defence Station")
: tr("Player Defence Station"));
m_entityTitleLabel->show();
float totalDps = 0.0f;
float maxRange = 0.0f;
bool hasWeapons = false;
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
{
if (owner.owner != entity) { return; }
hasWeapons = true;
totalDps += w.damage * w.fireRateHz;
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
});
QString statsText = tr("HP: %1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f));
if (hasWeapons)
{
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
}
m_stationStatsLabel->setText(statsText);
m_stationStatsLabel->show();
m_entityStatsPanel->hide();
}
void SelectedBuildingPanel::refreshEntityStats()
{
// 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();
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) { return; }
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
}
void SelectedBuildingPanel::clearEntityDisplay()
{
m_selectedEntities.clear();
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
} }
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event) void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
@@ -1167,38 +864,8 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEv
void SelectedBuildingPanel::handleEvent( void SelectedBuildingPanel::handleEvent(
std::shared_ptr<const DebrisSelectionChangedEvent> event) std::shared_ptr<const DebrisSelectionChangedEvent> event)
{ {
m_selectedDebris = event->debris; // Debris is a field object: it supersedes any building selection but coexists
if (!m_selectedDebris.empty()) // with actors (REQ-UI-SELECTION-CATEGORIES).
{ m_fieldSelectionPanel->setSelectedDebris(event->debris);
// Debris is a field object: it supersedes any building selection but coexists yieldToFieldSelection();
// with actors (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear();
}
buildFieldSelection();
}
int SelectedBuildingPanel::selectedDebrisScrapTotal() const
{
// Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL).
int total = 0;
for (const DebrisInfo& info : m_sim->getDebrisSystem().getAllDebrisInfo())
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end())
{
total += info.amount;
}
}
return total;
}
QString SelectedBuildingPanel::scrapTotalText() const
{
return tr("Scrap x %1").arg(selectedDebrisScrapTotal());
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
{
m_debugDraw = event->active;
m_entityStatsPanel->setDebugDrawEnabled(event->active);
} }

View File

@@ -7,12 +7,9 @@
#include <QPoint> #include <QPoint>
#include <QWidget> #include <QWidget>
#include "entt/entity/entity.hpp"
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectionChangedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "GameConfig.h" #include "GameConfig.h"
@@ -26,20 +23,27 @@
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
class Simulation; class Simulation;
class FieldSelectionPanel;
class ShipLayoutPreview; class ShipLayoutPreview;
class ShipStatsPanel;
class QLabel; class QLabel;
class QListWidget; class QListWidget;
class QPushButton; class QPushButton;
class QVBoxLayout; class QVBoxLayout;
// Shows the current selection. The building category (buildings and construction sites)
// is rendered by this panel itself; the field category (ships, defence stations, debris)
// is rendered by the embedded FieldSelectionPanel.
//
// The two categories are mutually exclusive (REQ-UI-SELECTION-CATEGORIES) and this panel
// is the sole arbiter of which one owns the content: it listens to all three selection
// events, forwards the field ones to the child panel, and drops the losing category's
// content. Neither panel touches the other's widgets.
class SelectedBuildingPanel : public QWidget, class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent, PlayerCommandsAppliedEvent,
EntitySelectionChangedEvent, EntitySelectionChangedEvent,
SelectionChangedEvent, SelectionChangedEvent,
DebrisSelectionChangedEvent, DebrisSelectionChangedEvent>
DebugDrawToggledEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -54,7 +58,6 @@ private:
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override; void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override; void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
private slots: private slots:
void onSelectRecipeClicked(); void onSelectRecipeClicked();
@@ -73,17 +76,14 @@ private:
}; };
void onSelectionChanged(const std::vector<BuildingId>& ids); void onSelectionChanged(const std::vector<BuildingId>& ids);
// Gives the panel to the field category once it has anything selected.
void yieldToFieldSelection();
void refreshSelectionDisplay(RefreshReason reason); void refreshSelectionDisplay(RefreshReason reason);
void rebuild(); void rebuild();
void hideAllWidgets(); void hideAllWidgets();
void clearContent();
void buildEmpty(); void buildEmpty();
void buildSingle(BuildingId id); void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids); void buildMulti(const std::vector<BuildingId>& ids);
// Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL).
int selectedDebrisScrapTotal() const;
// "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION).
QString scrapTotalText() const;
void refreshBuffers(const Building* b); void refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s); void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type, void updateShipyardLayoutWidgets(BuildingType type,
@@ -116,28 +116,7 @@ private:
QPoint m_splitterTile; QPoint m_splitterTile;
std::string m_currentRecipeId; std::string m_currentRecipeId;
bool m_debugDraw = false; // Renders the field selection (actors + debris) below the building content
// The selected ships/defence stations. Shares the "field" selection category with // (REQ-UI-FIELD-MULTI-SELECTION). Hides itself while nothing field-side is selected.
// debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES). FieldSelectionPanel* m_fieldSelectionPanel;
std::vector<entt::entity> m_selectedEntities;
ShipStatsPanel* m_entityStatsPanel;
QLabel* m_entityTitleLabel;
QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
std::vector<entt::entity> m_selectedDebris;
// Shows the debris "Scrap" stat row (single selection) — the scrap total for the
// multi-object summary lives in m_entitySummaryLabel instead.
QLabel* m_scrapLabel;
// Renders the combined field selection (actors + debris): a single-object stats panel
// (ship, station, or debris) or a multi-object count summary that appends the debris
// count and scrap total when debris is also selected (REQ-UI-FIELD-MULTI-SELECTION).
void buildFieldSelection();
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void buildDebrisSingle();
void refreshEntityStats();
void clearEntityDisplay();
}; };