diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 738218b..820d28e 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -9,6 +9,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h + ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h @@ -30,6 +31,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp diff --git a/src/ui/FieldSelectionPanel.cpp b/src/ui/FieldSelectionPanel.cpp new file mode 100644 index 0000000..679381d --- /dev/null +++ b/src/ui/FieldSelectionPanel.cpp @@ -0,0 +1,403 @@ +#include "FieldSelectionPanel.h" + +#include +#include +#include + +#include +#include +#include +#include + +#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& entities) +{ + m_selectedEntities = entities; + rebuild(); +} + +void FieldSelectionPanel::setSelectedDebris(const std::vector& 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(entity)) + { + buildEntityShip(entity); + } + else if (admin.isValid(entity) && admin.hasAll(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 keys; + std::map counts; + std::map labels; + + 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; + } + + // One " x " 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 " line followed by a + // "Scrap x " 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(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(entity); + const HealthComponent& health = admin.get(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(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(entity); + + const bool isEnemy = admin.hasAll(entity) + && admin.get(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( + [&](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(health.hp + 0.5f)) + .arg(static_cast(health.maxHp + 0.5f)); + + if (hasWeapons) + { + statsText += tr("\nDPS: %1").arg(QString::number(static_cast(totalDps), 'f', 1)); + statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast(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(entity)) { return; } + const HealthComponent& health = admin.get(entity); + if (health.hp <= 0.0f) { return; } + + if (admin.hasAll(entity)) + { + const ShipStats stats = buildShipStatsFromEntity(admin, entity); + m_entityStatsPanel->refreshFromLive(stats, health.hp); + m_entityStatsPanel->setBehavior( + admin.get(entity).winner); + } + else if (admin.hasAll(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 /*event*/) +{ + refreshDisplay(); +} + +void FieldSelectionPanel::handleEvent( + std::shared_ptr /*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 event) +{ + m_debugDraw = event->active; + m_entityStatsPanel->setDebugDrawEnabled(event->active); +} diff --git a/src/ui/FieldSelectionPanel.h b/src/ui/FieldSelectionPanel.h new file mode 100644 index 0000000..540d7bb --- /dev/null +++ b/src/ui/FieldSelectionPanel.h @@ -0,0 +1,91 @@ +#pragma once + +#include + +#include +#include + +#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 +{ + 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& entities); + // Replaces the selected debris; the selected actors are left alone. + void setSelectedDebris(const std::vector& 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 event) override; + void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr 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 m_selectedEntities; + std::vector 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; +}; diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index 5c6f79e..84bc502 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -9,26 +9,14 @@ #include #include #include -#include #include #include "BeltSystem.h" #include "Command.h" #include "CommandRequestedEvent.h" -#include "DisplayName.h" -#include "DynamicBodyComponent.h" -#include "EntityAdmin.h" #include "EntitySelectionChangedEvent.h" #include "EventManager.h" -#include "FactionComponent.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 "FieldSelectionPanel.h" #include "TickAdvancedEvent.h" #include "Building.h" #include "BuildingSystem.h" @@ -40,10 +28,8 @@ #include "RecipeSelectionDialog.h" #include "RecipeSelectionRequestedEvent.h" #include "Rotation.h" -#include "DebrisSystem.h" #include "ShipLayoutPreview.h" #include "Simulation.h" -#include "WeaponComponent.h" namespace { @@ -182,30 +168,10 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim, connect(m_filterBList, &QListWidget::itemChanged, this, &SelectedBuildingPanel::onSplitterFilterChanged); - 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(); + // The field selection renders below the building content and hides itself while + // nothing field-side is selected, so it costs no space then. + m_fieldSelectionPanel = new FieldSelectionPanel(sim, config, this); + m_layout->addWidget(m_fieldSelectionPanel); buildEmpty(); @@ -224,13 +190,21 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector& id { // A building selection is exclusive: it supersedes any field selection — // actors and scrap (REQ-UI-SELECTION-CATEGORIES). - clearEntityDisplay(); - m_selectedDebris.clear(); - m_scrapLabel->hide(); + m_fieldSelectionPanel->clearSelection(); } 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() { if (m_selectedBuildingIds.empty()) @@ -259,22 +233,14 @@ void SelectedBuildingPanel::hideAllWidgets() m_filterBLabel->hide(); m_filterBList->hide(); m_buffersLabel->hide(); - m_scrapLabel->hide(); -} - -void SelectedBuildingPanel::clearContent() -{ - m_singleBuildingId = std::nullopt; - hideAllWidgets(); } void SelectedBuildingPanel::buildEmpty() { - clearContent(); - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - m_entitySummaryLabel->hide(); + // Shows nothing for the building category — either because nothing is selected or + // because the field category has taken the panel over. + m_singleBuildingId = std::nullopt; + hideAllWidgets(); } void SelectedBuildingPanel::buildSingle(BuildingId id) @@ -646,27 +612,9 @@ void SelectedBuildingPanel::handleEvent( void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason) { - if (!m_selectedEntities.empty() || !m_selectedDebris.empty()) - { - // Field selection. 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 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; - } - + // 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 + // this returns immediately and the field panel refreshes itself off the same events. if (!m_singleBuildingId.has_value()) { return; } const Building* b = m_sim->getBuildings().findBuilding(*m_singleBuildingId); if (b) @@ -904,259 +852,8 @@ void SelectedBuildingPanel::onClearBelt() void SelectedBuildingPanel::handleEvent(std::shared_ptr event) { - m_selectedEntities = event->entities; - if (!m_selectedEntities.empty()) - { - // 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(entity)) - { - buildEntityShip(entity); - } - else if (admin.isValid(entity) && admin.hasAll(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 keys; - std::map counts; - std::map labels; - - 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; - } - - // One " x " 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 " line followed by a - // "Scrap x " 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(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(entity); - const HealthComponent& health = admin.get(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(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(entity); - - const bool isEnemy = admin.hasAll(entity) - && admin.get(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( - [&](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(health.hp + 0.5f)) - .arg(static_cast(health.maxHp + 0.5f)); - - if (hasWeapons) - { - statsText += tr("\nDPS: %1").arg(QString::number(static_cast(totalDps), 'f', 1)); - statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast(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(entity)) { return; } - const HealthComponent& health = admin.get(entity); - if (health.hp <= 0.0f) { return; } - - if (admin.hasAll(entity)) - { - const ShipStats stats = buildShipStatsFromEntity(admin, entity); - m_entityStatsPanel->refreshFromLive(stats, health.hp); - m_entityStatsPanel->setBehavior( - admin.get(entity).winner); - } - else if (admin.hasAll(entity)) - { - buildEntityStation(entity); - } -} - -void SelectedBuildingPanel::clearEntityDisplay() -{ - m_selectedEntities.clear(); - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - m_entitySummaryLabel->hide(); + m_fieldSelectionPanel->setSelectedEntities(event->entities); + yieldToFieldSelection(); } void SelectedBuildingPanel::handleEvent(std::shared_ptr event) @@ -1167,38 +864,8 @@ void SelectedBuildingPanel::handleEvent(std::shared_ptr event) { - m_selectedDebris = event->debris; - if (!m_selectedDebris.empty()) - { - // Debris is a field object: it supersedes any building selection but coexists - // 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 event) -{ - m_debugDraw = event->active; - m_entityStatsPanel->setDebugDrawEnabled(event->active); + // Debris is a field object: it supersedes any building selection but coexists + // with actors (REQ-UI-SELECTION-CATEGORIES). + m_fieldSelectionPanel->setSelectedDebris(event->debris); + yieldToFieldSelection(); } diff --git a/src/ui/SelectedBuildingPanel.h b/src/ui/SelectedBuildingPanel.h index f2b6c4f..244442b 100644 --- a/src/ui/SelectedBuildingPanel.h +++ b/src/ui/SelectedBuildingPanel.h @@ -7,12 +7,9 @@ #include #include -#include "entt/entity/entity.hpp" - #include "BeltSystem.h" #include "Building.h" #include "BuildingId.h" -#include "DebugDrawToggledEvent.h" #include "EntitySelectionChangedEvent.h" #include "EventHandler.h" #include "GameConfig.h" @@ -26,20 +23,27 @@ #include "TickAdvancedEvent.h" class Simulation; +class FieldSelectionPanel; class ShipLayoutPreview; -class ShipStatsPanel; class QLabel; class QListWidget; class QPushButton; 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, public CombinedEventHandler + DebrisSelectionChangedEvent> { Q_OBJECT @@ -54,7 +58,6 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; - void handleEvent(std::shared_ptr event) override; private slots: void onSelectRecipeClicked(); @@ -73,17 +76,14 @@ private: }; void onSelectionChanged(const std::vector& ids); + // Gives the panel to the field category once it has anything selected. + void yieldToFieldSelection(); void refreshSelectionDisplay(RefreshReason reason); void rebuild(); void hideAllWidgets(); - void clearContent(); void buildEmpty(); void buildSingle(BuildingId id); void buildMulti(const std::vector& 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 refreshSiteProgress(const ConstructionSite* s); void updateShipyardLayoutWidgets(BuildingType type, @@ -116,28 +116,7 @@ private: QPoint m_splitterTile; std::string m_currentRecipeId; - 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 m_selectedEntities; - ShipStatsPanel* m_entityStatsPanel; - QLabel* m_entityTitleLabel; - QLabel* m_stationStatsLabel; - QLabel* m_entitySummaryLabel; - - std::vector 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(); + // Renders the field selection (actors + debris) below the building content + // (REQ-UI-FIELD-MULTI-SELECTION). Hides itself while nothing field-side is selected. + FieldSelectionPanel* m_fieldSelectionPanel; };