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; +};