give the selection cards their own parts instead of label blobs

The cards were assembled from plain labels carrying whole blocks of text.
Replace those with the widget vocabulary the requirements describe, so a part
means the same thing wherever it appears and a card is a list of parts rather
than a string builder.

The parts, all free of Simulation and GameConfig -- they take prepared values,
and the contents work out what those are:
- StatRow, BarRow, SectionBox: label/value line, captioned fill bar, captioned
  group. The bar is one part for three things: construction progress,
  production progress, and HP.
- ItemChip / ItemChipRow: buffered items as icon, count and sub-line
  (REQ-UI-SINGLE-SELECTION). An input chip carries its per-cycle amount, an
  output chip its count against the buffer capacity. The chips are rebuilt only
  when the set of items changes, so a 30 Hz refresh moves numbers rather than
  widgets.
- RecipeSummaryRow: inputs, arrow, outputs, cycle time (REQ-UI-RECIPE-SUMMARY),
  which is now the panel's only display of the cycle time.
- CountRow, StatusPill, EmptyNote.

Behaviour that changed with them:
- The station card shows damage, range and fire rate as the requirement asks
  (REQ-UI-STATION-STATS-PANEL) rather than the combined DPS it showed before.
- A ship's behaviour moves from a stats row into the card header
  (REQ-UI-SHIP-BEHAVIOR). ShipStatsPanel keeps setBehavior for the balancing
  tool's inspect window, which has no header to put it in.
- A construction site's card shows a progress bar and the "no buffers until
  built" note (REQ-UI-SELECTION-CARD), and now also its recipe summary, since
  that is configuration and a site carries it (REQ-BLD-SITE-CONFIG). Costing a
  shipyard site's schematic needed computeShipyardRequiredMaterials to take a
  stored configuration as well as a live building -- one overload, so the
  module sum still exists once.

ShipStatsPanel is rebuilt on StatRow, BarRow and SectionBox, so the selection
card, the layout dialog's design preview and the balancing tool read alike.
Those three parts are compiled into the balancing target, which does not link
the ui library; keeping them sim-free is what makes that possible, and the
build enforces it.

Build clean, 541 tests pass, app and balancing tool both run with no Qt
warnings. Visual check pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
2026-08-07 12:37:11 +02:00
parent 89e984ec76
commit 246cfc3935
54 changed files with 1505 additions and 533 deletions

View File

@@ -1,11 +1,14 @@
#include "ShipStatsPanel.h"
#include <QLabel>
#include <QString>
#include <QVBoxLayout>
#include "BarRow.h"
#include "GameConfig.h"
#include "SectionBox.h"
#include "SelectionNames.h"
#include "ShipStatsCalculator.h"
#include "StatRow.h"
#include "ThreatCostCalculator.h"
namespace
@@ -16,20 +19,6 @@ QString fmt(float value)
return QString::number(static_cast<double>(value), 'f', 1);
}
QLabel* makeSectionHeader(const QString& text, QWidget* parent)
{
QLabel* label = new QLabel(text, parent);
QFont f = label->font();
f.setBold(true);
label->setFont(f);
return label;
}
QLabel* makeStatLabel(QWidget* parent)
{
return new QLabel(parent);
}
} // namespace
@@ -38,215 +27,150 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
, m_config(config)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(2);
layout->setAlignment(Qt::AlignTop);
// Hull stats always visible.
m_hpLabel = makeStatLabel(this);
m_speedLabel = makeStatLabel(this);
m_sensorRangeLabel = makeStatLabel(this);
m_mainAccelLabel = makeStatLabel(this);
m_maneuveringAccelLabel = makeStatLabel(this);
m_angularAccelLabel = makeStatLabel(this);
m_maxRotSpeedLabel = makeStatLabel(this);
m_cargoCapacityLabel = makeStatLabel(this);
m_cargoCapacityLabel->setVisible(false);
// Hull stats -- always visible, except cargo capacity, which only a ship that can
// carry anything shows (REQ-MOD-UI-STATS-PANEL).
m_hpBar = new BarRow(tr("HP"), this);
m_speedRow = new StatRow(tr("Max speed"), this);
m_sensorRangeRow = new StatRow(tr("Sensor range"), this);
m_mainAccelRow = new StatRow(tr("Main accel"), this);
m_maneuveringAccelRow = new StatRow(tr("Maneuvering accel"), this);
m_angularAccelRow = new StatRow(tr("Angular accel"), this);
m_maxRotSpeedRow = new StatRow(tr("Max rotation"), this);
m_cargoCapacityRow = new StatRow(tr("Cargo capacity"), this);
m_cargoCapacityRow->hide();
layout->addWidget(m_hpLabel);
layout->addWidget(m_speedLabel);
layout->addWidget(m_sensorRangeLabel);
layout->addWidget(m_mainAccelLabel);
layout->addWidget(m_maneuveringAccelLabel);
layout->addWidget(m_angularAccelLabel);
layout->addWidget(m_maxRotSpeedLabel);
layout->addWidget(m_cargoCapacityLabel);
layout->addWidget(m_hpBar);
layout->addWidget(m_speedRow);
layout->addWidget(m_sensorRangeRow);
layout->addWidget(m_mainAccelRow);
layout->addWidget(m_maneuveringAccelRow);
layout->addWidget(m_angularAccelRow);
layout->addWidget(m_maxRotSpeedRow);
layout->addWidget(m_cargoCapacityRow);
// Weapon capability section.
m_weaponSection = new QWidget(this);
{
QVBoxLayout* sl = new QVBoxLayout(m_weaponSection);
sl->setContentsMargins(0, 4, 0, 0);
sl->setSpacing(2);
sl->addWidget(makeSectionHeader(tr("Weapons"), m_weaponSection));
m_weaponDpsLabel = makeStatLabel(m_weaponSection);
m_weaponRangeLabel = makeStatLabel(m_weaponSection);
sl->addWidget(m_weaponDpsLabel);
sl->addWidget(m_weaponRangeLabel);
}
m_weaponSection->setVisible(false);
// One section per capability module type, each shown only while at least one such
// module is installed (REQ-MOD-UI-STATS-PANEL).
m_weaponSection = new SectionBox(tr("Weapons"), this);
m_weaponDpsRow = new StatRow(tr("DPS"), m_weaponSection);
m_weaponRangeRow = new StatRow(tr("Range"), m_weaponSection);
m_weaponSection->getContentLayout()->addWidget(m_weaponDpsRow);
m_weaponSection->getContentLayout()->addWidget(m_weaponRangeRow);
m_weaponSection->hide();
layout->addWidget(m_weaponSection);
// Salvage capability section.
m_salvageSection = new QWidget(this);
{
QVBoxLayout* sl = new QVBoxLayout(m_salvageSection);
sl->setContentsMargins(0, 4, 0, 0);
sl->setSpacing(2);
sl->addWidget(makeSectionHeader(tr("Salvage"), m_salvageSection));
m_salvageRateLabel = makeStatLabel(m_salvageSection);
m_salvageRangeLabel = makeStatLabel(m_salvageSection);
sl->addWidget(m_salvageRateLabel);
sl->addWidget(m_salvageRangeLabel);
}
m_salvageSection->setVisible(false);
m_salvageSection = new SectionBox(tr("Salvage"), this);
m_salvageRateRow = new StatRow(tr("Collection rate"), m_salvageSection);
m_salvageRangeRow = new StatRow(tr("Range"), m_salvageSection);
m_salvageSection->getContentLayout()->addWidget(m_salvageRateRow);
m_salvageSection->getContentLayout()->addWidget(m_salvageRangeRow);
m_salvageSection->hide();
layout->addWidget(m_salvageSection);
// Repair capability section.
m_repairSection = new QWidget(this);
{
QVBoxLayout* sl = new QVBoxLayout(m_repairSection);
sl->setContentsMargins(0, 4, 0, 0);
sl->setSpacing(2);
sl->addWidget(makeSectionHeader(tr("Repair"), m_repairSection));
m_repairRateLabel = makeStatLabel(m_repairSection);
m_repairRangeLabel = makeStatLabel(m_repairSection);
sl->addWidget(m_repairRateLabel);
sl->addWidget(m_repairRangeLabel);
}
m_repairSection->setVisible(false);
m_repairSection = new SectionBox(tr("Repair"), this);
m_repairRateRow = new StatRow(tr("Repair rate"), m_repairSection);
m_repairRangeRow = new StatRow(tr("Range"), m_repairSection);
m_repairSection->getContentLayout()->addWidget(m_repairRateRow);
m_repairSection->getContentLayout()->addWidget(m_repairRangeRow);
m_repairSection->hide();
layout->addWidget(m_repairSection);
// Current behavior — live entities only; hidden in the static design
// preview (REQ-UI-SHIP-BEHAVIOR).
m_behaviorLabel = makeSectionHeader(QString(), this);
m_behaviorLabel->setVisible(false);
layout->addWidget(m_behaviorLabel);
// Live entities only; the design preview has no behavior to show
// (REQ-UI-SHIP-BEHAVIOR).
m_behaviorRow = new StatRow(tr("Behavior"), this);
m_behaviorRow->hide();
layout->addWidget(m_behaviorRow);
// Threat cost — debug-only, initially hidden.
m_threatCostLabel = makeStatLabel(this);
m_threatCostLabel->setVisible(false);
layout->addWidget(m_threatCostLabel);
layout->addStretch();
}
void ShipStatsPanel::refresh(const std::string& shipId,
const std::vector<PlacedModule>& modules)
{
const ShipStats stats = calculateShipStats(*m_config, shipId, modules);
const QString hpText = tr("HP: %1").arg(static_cast<int>(stats.hp + 0.5f));
applyStats(stats, hpText);
const double threat = calculateShipThreatCost(m_config->threatCosts, *m_config,
shipId, modules);
setThreatCost(threat);
// The static design preview has no live behavior to show.
m_behaviorLabel->setVisible(false);
}
void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp)
{
const QString hpText = tr("HP: %1 / %2")
.arg(static_cast<int>(currentHp + 0.5f))
.arg(static_cast<int>(stats.hp + 0.5f));
applyStats(stats, hpText);
}
void ShipStatsPanel::applyStats(const ShipStats& stats, const QString& hpText)
{
m_hpLabel->setText(hpText);
m_speedLabel->setText(
tr("Max Speed: %1 tiles/s").arg(fmt(stats.maxSpeed_tps)));
m_sensorRangeLabel->setText(
tr("Sensor Range: %1 tiles").arg(fmt(stats.sensorRange_tiles)));
m_mainAccelLabel->setText(
tr("Main Accel: %1 tiles/s\xc2\xb2").arg(fmt(stats.mainAcceleration_tpss)));
m_maneuveringAccelLabel->setText(
tr("Maneuvering Accel: %1 tiles/s\xc2\xb2").arg(fmt(stats.maneuveringAcceleration_tpss)));
m_angularAccelLabel->setText(
tr("Angular Accel: %1 rad/s\xc2\xb2").arg(fmt(stats.angularAcceleration_radpss)));
m_maxRotSpeedLabel->setText(
tr("Max Rotation: %1 rad/s").arg(fmt(stats.maxRotationSpeed_radps)));
// Cargo capacity is shown only when the ship can actually hold cargo
// (REQ-MOD-UI-STATS-PANEL).
if (stats.cargoCapacity > 0)
{
m_cargoCapacityLabel->setText(
tr("Cargo Capacity: %1").arg(stats.cargoCapacity));
m_cargoCapacityLabel->setVisible(true);
}
else
{
m_cargoCapacityLabel->setVisible(false);
}
if (stats.weapons.has_value())
{
m_weaponDpsLabel->setText(
tr("DPS: %1").arg(fmt(stats.weapons->combinedDps)));
m_weaponRangeLabel->setText(
tr("Range: %1 tiles").arg(fmt(stats.weapons->maxRange_tiles)));
m_weaponSection->setVisible(true);
}
else
{
m_weaponSection->setVisible(false);
}
if (stats.salvage.has_value())
{
m_salvageRateLabel->setText(
tr("Collection Rate: %1/s").arg(fmt(stats.salvage->combinedCollectionRate)));
m_salvageRangeLabel->setText(
tr("Range: %1 tiles").arg(fmt(stats.salvage->maxRange_tiles)));
m_salvageSection->setVisible(true);
}
else
{
m_salvageSection->setVisible(false);
}
if (stats.repair.has_value())
{
m_repairRateLabel->setText(
tr("Repair Rate: %1 HP/s").arg(fmt(stats.repair->combinedRepairRate_hps)));
m_repairRangeLabel->setText(
tr("Range: %1 tiles").arg(fmt(stats.repair->maxRange_tiles)));
m_repairSection->setVisible(true);
}
else
{
m_repairSection->setVisible(false);
}
// Threat cost -- shown only while debug draw is active (REQ-UI-SHIP-STATS-PANEL).
m_threatCostRow = new StatRow(tr("Threat cost"), this);
m_threatCostRow->hide();
layout->addWidget(m_threatCostRow);
}
void ShipStatsPanel::setBehavior(BehaviorKind kind)
{
QString label;
switch (kind)
const QString label = getBehaviorLabel(kind);
m_behaviorRow->setValue(label);
m_behaviorRow->setVisible(!label.isEmpty());
}
void ShipStatsPanel::refresh(const std::string& shipId,
const std::vector<PlacedModule>& modules)
{
const ShipStats stats = calculateShipStats(*m_config, shipId, modules);
applyStats(stats, 1.0, QString::number(static_cast<int>(stats.hp + 0.5f)));
setThreatCost(calculateShipThreatCost(m_config->threatCosts, *m_config,
shipId, modules));
}
void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp)
{
const double fraction = (stats.hp > 0.0f)
? static_cast<double>(currentHp) / stats.hp
: 0.0;
applyStats(stats, fraction, tr("%1 / %2")
.arg(static_cast<int>(currentHp + 0.5f))
.arg(static_cast<int>(stats.hp + 0.5f)));
}
void ShipStatsPanel::applyStats(const ShipStats& stats, double hpFraction,
const QString& hpText)
{
m_hpBar->setValue(hpFraction, hpText);
m_speedRow->setValue(tr("%1 tiles/s").arg(fmt(stats.maxSpeed_tps)));
m_sensorRangeRow->setValue(tr("%1 tiles").arg(fmt(stats.sensorRange_tiles)));
m_mainAccelRow->setValue(
tr("%1 tiles/s\xc2\xb2").arg(fmt(stats.mainAcceleration_tpss)));
m_maneuveringAccelRow->setValue(
tr("%1 tiles/s\xc2\xb2").arg(fmt(stats.maneuveringAcceleration_tpss)));
m_angularAccelRow->setValue(
tr("%1 rad/s\xc2\xb2").arg(fmt(stats.angularAcceleration_radpss)));
m_maxRotSpeedRow->setValue(tr("%1 rad/s").arg(fmt(stats.maxRotationSpeed_radps)));
// Cargo capacity is shown only when the ship can actually hold cargo
// (REQ-MOD-UI-STATS-PANEL).
m_cargoCapacityRow->setVisible(stats.cargoCapacity > 0);
if (stats.cargoCapacity > 0)
{
case BehaviorKind::Retreat: label = tr("Retreating"); break;
case BehaviorKind::Attack: label = tr("Engaging"); break;
case BehaviorKind::SalvageScrap:
case BehaviorKind::DeliverScrap: label = tr("Salvaging"); break;
case BehaviorKind::Repair: label = tr("Repairing"); break;
case BehaviorKind::Rally: label = tr("Rallying"); break;
case BehaviorKind::Standby: label = tr("Standby"); break;
case BehaviorKind::Advance: label = tr("Advancing"); break;
case BehaviorKind::None: break;
m_cargoCapacityRow->setValue(QString::number(stats.cargoCapacity));
}
if (label.isEmpty())
m_weaponSection->setVisible(stats.weapons.has_value());
if (stats.weapons.has_value())
{
m_behaviorLabel->setVisible(false);
return;
m_weaponDpsRow->setValue(fmt(stats.weapons->combinedDps));
m_weaponRangeRow->setValue(tr("%1 tiles").arg(fmt(stats.weapons->maxRange_tiles)));
}
m_behaviorLabel->setText(tr("Behavior: %1").arg(label));
m_behaviorLabel->setVisible(true);
m_salvageSection->setVisible(stats.salvage.has_value());
if (stats.salvage.has_value())
{
m_salvageRateRow->setValue(
tr("%1 /s").arg(fmt(stats.salvage->combinedCollectionRate)));
m_salvageRangeRow->setValue(tr("%1 tiles").arg(fmt(stats.salvage->maxRange_tiles)));
}
m_repairSection->setVisible(stats.repair.has_value());
if (stats.repair.has_value())
{
m_repairRateRow->setValue(
tr("%1 HP/s").arg(fmt(stats.repair->combinedRepairRate_hps)));
m_repairRangeRow->setValue(tr("%1 tiles").arg(fmt(stats.repair->maxRange_tiles)));
}
}
void ShipStatsPanel::setThreatCost(double cost)
{
m_threatCostLabel->setText(tr("Threat Cost: %1").arg(cost, 0, 'f', 1));
m_threatCostLabel->setVisible(m_debugDraw);
m_threatCostRow->setValue(QString::number(cost, 'f', 1));
m_threatCostRow->setVisible(m_debugDraw);
}
void ShipStatsPanel::setDebugDrawEnabled(bool enabled)
{
m_debugDraw = enabled;
m_threatCostLabel->setVisible(m_debugDraw);
m_threatCostRow->setVisible(m_debugDraw);
}