404 lines
13 KiB
C++
404 lines
13 KiB
C++
#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 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<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 : 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<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);
|
|
}
|