#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 SelectionPanel 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 : getAllDebrisInfo(m_sim->getAdmin())) { 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); }