diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 319bca3..285c8fd 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -10,7 +10,12 @@ set(TARGET_LIB_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/lib" "${CMAKE_CURRENT_SOURCE_DIR}/external" ) -set(TARGET_UI_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/ui") +set(TARGET_UI_INCLUDE_DIRS + "${CMAKE_CURRENT_SOURCE_DIR}/ui" + # The balancing target compiles a few ui files into itself rather than linking the + # ui library, and the ship stats panel is built from the selection card's parts. + "${CMAKE_CURRENT_SOURCE_DIR}/ui/selection" +) set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test") set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing") diff --git a/src/balancing/CMakeLists.txt b/src/balancing/CMakeLists.txt index fd0ad65..5f35ea0 100644 --- a/src/balancing/CMakeLists.txt +++ b/src/balancing/CMakeLists.txt @@ -7,6 +7,12 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h + # The card parts the ship stats panel is built from. They are deliberately free of + # Simulation and GameConfig, which is what lets them come along here. + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/StatRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.h + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h # Shared world-space shapes so the arena keeps looking like the game @@ -26,6 +32,10 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/StatRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp PARENT_SCOPE diff --git a/src/lib/sim/ProductionRules.cpp b/src/lib/sim/ProductionRules.cpp index ee4d191..4812084 100644 --- a/src/lib/sim/ProductionRules.cpp +++ b/src/lib/sim/ProductionRules.cpp @@ -45,9 +45,17 @@ bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe) } std::map computeShipyardRequiredMaterials(const GameConfig& config, const Building& b) +{ + return computeShipyardRequiredMaterials(config, b.recipeId, b.shipLayout); +} + +std::map +computeShipyardRequiredMaterials(const GameConfig& config, + const std::string& recipeId, + const std::optional& shipLayout) { std::map requiredMaterials; - const ShipDef* shipDef = config.ships.findShipDef(b.recipeId); + const ShipDef* shipDef = config.ships.findShipDef(recipeId); if (!shipDef) { return requiredMaterials; @@ -56,9 +64,9 @@ computeShipyardRequiredMaterials(const GameConfig& config, const Building& b) { requiredMaterials[ing.item] += ing.amount; } - if (b.shipLayout.has_value()) + if (shipLayout.has_value()) { - for (const PlacedModule& pm : b.shipLayout->placedModules) + for (const PlacedModule& pm : shipLayout->placedModules) { const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId); if (!modDef) diff --git a/src/lib/sim/ProductionRules.h b/src/lib/sim/ProductionRules.h index caba2ec..78e69c1 100644 --- a/src/lib/sim/ProductionRules.h +++ b/src/lib/sim/ProductionRules.h @@ -38,6 +38,12 @@ bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe); std::map computeShipyardRequiredMaterials(const GameConfig& config, const Building& b); +// The same sum over a stored configuration rather than an operational building, so a +// construction site's schematic can be costed before it is built (REQ-BLD-SITE-CONFIG). +std::map computeShipyardRequiredMaterials( + const GameConfig& config, const std::string& recipeId, + const std::optional& shipLayout); + // True when a production cycle could start right now, ignoring output-buffer space. bool hasInputsToStart(const GameConfig& config, const Building& b); diff --git a/src/ui/ShipStatsPanel.cpp b/src/ui/ShipStatsPanel.cpp index 636bd17..e193553 100644 --- a/src/ui/ShipStatsPanel.cpp +++ b/src/ui/ShipStatsPanel.cpp @@ -1,11 +1,14 @@ #include "ShipStatsPanel.h" -#include #include #include +#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(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& modules) -{ - const ShipStats stats = calculateShipStats(*m_config, shipId, modules); - const QString hpText = tr("HP: %1").arg(static_cast(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(currentHp + 0.5f)) - .arg(static_cast(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& modules) +{ + const ShipStats stats = calculateShipStats(*m_config, shipId, modules); + applyStats(stats, 1.0, QString::number(static_cast(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(currentHp) / stats.hp + : 0.0; + applyStats(stats, fraction, tr("%1 / %2") + .arg(static_cast(currentHp + 0.5f)) + .arg(static_cast(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); } diff --git a/src/ui/ShipStatsPanel.h b/src/ui/ShipStatsPanel.h index d7745bb..300b341 100644 --- a/src/ui/ShipStatsPanel.h +++ b/src/ui/ShipStatsPanel.h @@ -1,18 +1,28 @@ #pragma once -#include #include #include #include -#include "BehaviorKind.h" #include "ShipLayout.h" #include "ShipStatsCalculator.h" -struct GameConfig; -class QLabel; +#include "BehaviorKind.h" +struct GameConfig; +class BarRow; +class SectionBox; +class StatRow; + +// The hull stats and capability module summaries of one ship, as a bar for HP and a +// label/value row for everything else. Shared by the live selection card +// (REQ-UI-SHIP-STATS-PANEL), the layout configuration dialog's design preview +// (REQ-MOD-UI-STATS-PANEL) and the balancing tool, so all three read alike. +// +// The behavior row is for consumers with no header to put it in. The selection card +// shows the behavior in its header instead (REQ-UI-SHIP-BEHAVIOR) and leaves the row +// unset; the design preview has no live ship to have a behavior at all. class ShipStatsPanel : public QWidget { Q_OBJECT @@ -20,44 +30,47 @@ class ShipStatsPanel : public QWidget public: explicit ShipStatsPanel(const GameConfig* config, QWidget* parent = nullptr); + // Stats of a design rather than of a live ship: the HP bar reads full, because the + // number shown is the maximum the design would have. void refresh(const std::string& shipId, const std::vector& modules); void refreshFromLive(const ShipStats& stats, float currentHp); - // Displays the ship's current top-priority behavior (REQ-UI-SHIP-BEHAVIOR). + // Shows the ship's top-priority behavior as a row of its own. Never called by the + // selection card, which has a header slot for it. void setBehavior(BehaviorKind kind); void setThreatCost(double cost); void setDebugDrawEnabled(bool enabled); private: - void applyStats(const ShipStats& stats, const QString& hpText); + void applyStats(const ShipStats& stats, double hpFraction, const QString& hpText); const GameConfig* m_config; bool m_debugDraw = false; - QLabel* m_behaviorLabel; - QLabel* m_hpLabel; - QLabel* m_speedLabel; - QLabel* m_sensorRangeLabel; - QLabel* m_mainAccelLabel; - QLabel* m_maneuveringAccelLabel; - QLabel* m_angularAccelLabel; - QLabel* m_maxRotSpeedLabel; - QLabel* m_cargoCapacityLabel; + BarRow* m_hpBar; + StatRow* m_speedRow; + StatRow* m_sensorRangeRow; + StatRow* m_mainAccelRow; + StatRow* m_maneuveringAccelRow; + StatRow* m_angularAccelRow; + StatRow* m_maxRotSpeedRow; + StatRow* m_cargoCapacityRow; - QWidget* m_weaponSection; - QLabel* m_weaponDpsLabel; - QLabel* m_weaponRangeLabel; + SectionBox* m_weaponSection; + StatRow* m_weaponDpsRow; + StatRow* m_weaponRangeRow; - QWidget* m_salvageSection; - QLabel* m_salvageRateLabel; - QLabel* m_salvageRangeLabel; + SectionBox* m_salvageSection; + StatRow* m_salvageRateRow; + StatRow* m_salvageRangeRow; - QWidget* m_repairSection; - QLabel* m_repairRateLabel; - QLabel* m_repairRangeLabel; + SectionBox* m_repairSection; + StatRow* m_repairRateRow; + StatRow* m_repairRangeRow; - QLabel* m_threatCostLabel; + StatRow* m_behaviorRow; + StatRow* m_threatCostRow; }; diff --git a/src/ui/selection/AutoProductionContent.cpp b/src/ui/selection/AutoProductionContent.cpp index 4cff9dd..58fbf5f 100644 --- a/src/ui/selection/AutoProductionContent.cpp +++ b/src/ui/selection/AutoProductionContent.cpp @@ -3,7 +3,6 @@ #include "Building.h" #include "BuildingTarget.h" #include "GameConfig.h" -#include "SelectionNames.h" AutoProductionContent::AutoProductionContent(const SelectionContext& context, const SelectionRequest& request, @@ -12,31 +11,21 @@ AutoProductionContent::AutoProductionContent(const SelectionContext& context, { } -void AutoProductionContent::refreshConfiguration() -{ - const BuildingTarget target = resolveBuildingTarget(getContext(), getBuildingId()); - if (!target.isValid()) - { - return; - } - setBuildingIdentity(target.type, getBuildingTypeName(target.type)); -} - BufferedBuildingContent::CycleInfo AutoProductionContent::getCycleInfo( - const Building& building) const + const BuildingTarget& target) const { CycleInfo info; // An auto-recipe building always runs an implicit recipe (REQ-BLD-SMELTER, // REQ-BLD-REPROCESSING), so its production section is always shown -- but only a - // running cycle names a recipe, so while it is idle there is no cycle time. + // running cycle names a recipe, so while it is idle there is no cycle to describe. info.runsProduction = true; - if (!building.production.has_value()) + if (!target.building || !target.building->production.has_value()) { return info; } const RecipeDef* recipe = getContext().config->recipes.findRecipeDef( - building.production->recipeId, building.type); + target.building->production->recipeId, target.type); if (!recipe) { return info; diff --git a/src/ui/selection/AutoProductionContent.h b/src/ui/selection/AutoProductionContent.h index aeac346..b172218 100644 --- a/src/ui/selection/AutoProductionContent.h +++ b/src/ui/selection/AutoProductionContent.h @@ -16,6 +16,5 @@ public: const SelectionRequest& request, QWidget* parent = nullptr); protected: - void refreshConfiguration() override; - CycleInfo getCycleInfo(const Building& building) const override; + CycleInfo getCycleInfo(const BuildingTarget& target) const override; }; diff --git a/src/ui/selection/BarRow.cpp b/src/ui/selection/BarRow.cpp new file mode 100644 index 0000000..fcc562e --- /dev/null +++ b/src/ui/selection/BarRow.cpp @@ -0,0 +1,118 @@ +#include "BarRow.h" + +#include + +#include +#include +#include +#include +#include + +namespace +{ + +// Height and corner radius of the fill bar, in device-independent pixels. +const int kBarHeightPx = 6; +const qreal kBarRadiusPx = 3.0; + +// Opacity of the unfilled track, over the card's background. +const int kTrackAlpha = 60; + +} // namespace + + +// The bar itself. Painted rather than assembled from a QProgressBar, because all it +// needs is a rounded track with a rounded fill and a style sheet cannot be relied on to +// leave a progress bar's groove and chunk alone across styles. +class BarRow::Bar : public QWidget +{ +public: + explicit Bar(QWidget* parent) + : QWidget(parent) + , m_fraction(0.0) + { + setFixedHeight(kBarHeightPx); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + } + + void setFraction(double fraction) + { + m_fraction = std::max(0.0, std::min(1.0, fraction)); + update(); + } + + void setFillColor(const QColor& color) + { + m_fillColor = color; + update(); + } + +protected: + void paintEvent(QPaintEvent* /*event*/) override + { + const QColor fill = m_fillColor.isValid() + ? m_fillColor + : palette().color(QPalette::Highlight); + + QColor track = fill; + track.setAlpha(kTrackAlpha); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(Qt::NoPen); + + painter.setBrush(track); + painter.drawRoundedRect(rect(), kBarRadiusPx, kBarRadiusPx); + + if (m_fraction > 0.0) + { + QRect filled = rect(); + filled.setWidth(static_cast(filled.width() * m_fraction)); + painter.setBrush(fill); + painter.drawRoundedRect(filled, kBarRadiusPx, kBarRadiusPx); + } + } + +private: + double m_fraction; + QColor m_fillColor; +}; + + +BarRow::BarRow(const QString& caption, QWidget* parent) + : QWidget(parent) +{ + QVBoxLayout* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(2); + + QWidget* captionRow = new QWidget(this); + QHBoxLayout* captionLayout = new QHBoxLayout(captionRow); + captionLayout->setContentsMargins(0, 0, 0, 0); + captionLayout->setSpacing(8); + + m_captionLabel = new QLabel(caption, captionRow); + m_captionLabel->setVisible(!caption.isEmpty()); + m_valueLabel = new QLabel(captionRow); + m_valueLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + captionLayout->addWidget(m_captionLabel); + captionLayout->addStretch(1); + captionLayout->addWidget(m_valueLabel); + + m_bar = new Bar(this); + + layout->addWidget(captionRow); + layout->addWidget(m_bar); +} + +void BarRow::setValue(double fraction, const QString& valueText) +{ + m_bar->setFraction(fraction); + m_valueLabel->setText(valueText); +} + +void BarRow::setFillColor(const QColor& color) +{ + m_bar->setFillColor(color); +} diff --git a/src/ui/selection/BarRow.h b/src/ui/selection/BarRow.h new file mode 100644 index 0000000..b2c6592 --- /dev/null +++ b/src/ui/selection/BarRow.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include + +class QLabel; + +// A caption with its value hard right and a horizontal fill bar beneath it. One part for +// the three things the panel shows as a proportion: a construction site's progress, a +// building's production cycle, and the HP of a ship, a station or the HQ +// (REQ-UI-PRODUCTION-PROGRESS, REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL, +// REQ-UI-HQ-PANEL). +// +// The caption may be left empty, for a bar whose meaning is already given by the section +// it sits in. +class BarRow : public QWidget +{ + Q_OBJECT + +public: + explicit BarRow(const QString& caption, QWidget* parent = nullptr); + + // fraction is clamped to [0, 1]; valueText is shown beside the caption as-is, so a + // bar can read "72%" or "340 / 500" as its meaning requires. + void setValue(double fraction, const QString& valueText); + // Overrides the fill color, which defaults to the palette's highlight. + void setFillColor(const QColor& color); + +private: + class Bar; + + QLabel* m_captionLabel; + QLabel* m_valueLabel; + Bar* m_bar; +}; diff --git a/src/ui/selection/BufferSection.cpp b/src/ui/selection/BufferSection.cpp index c2bbead..27c3643 100644 --- a/src/ui/selection/BufferSection.cpp +++ b/src/ui/selection/BufferSection.cpp @@ -1,24 +1,17 @@ #include "BufferSection.h" -#include +#include + #include #include "Building.h" +#include "DisplayName.h" +#include "ItemChipRow.h" +#include "SectionBox.h" namespace { -// One ": [/]" entry. -QString formatEntry(const std::string& itemId, int count, int perCycle) -{ - QString text = QString::fromStdString(itemId) + ": " + QString::number(count); - if (perCycle > 0) - { - text += "/" + QString::number(perCycle); - } - return text + " "; -} - int findPerCycle(const std::map& perCycle, const std::string& itemId) { const std::map::const_iterator it = perCycle.find(itemId); @@ -28,33 +21,42 @@ int findPerCycle(const std::map& perCycle, const std::string& } // namespace -BufferSection::BufferSection(QWidget* parent) +BufferSection::BufferSection(const SelectionContext& context, QWidget* parent) : QWidget(parent) { QVBoxLayout* layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); + layout->setSpacing(6); - m_label = new QLabel(this); - m_label->setWordWrap(true); - layout->addWidget(m_label); + m_inputSection = new SectionBox(tr("Input buffers"), this); + m_inputChips = new ItemChipRow(context.itemIcons, m_inputSection); + m_inputSection->getContentLayout()->addWidget(m_inputChips); + + m_outputSection = new SectionBox(tr("Output buffer"), this); + m_outputChips = new ItemChipRow(context.itemIcons, m_outputSection); + m_outputSection->getContentLayout()->addWidget(m_outputChips); + + layout->addWidget(m_inputSection); + layout->addWidget(m_outputSection); } void BufferSection::setBuffers(const Building& building, const std::map& perCycleInputs, const std::map& perCycleOutputs) { - QString text; - - if (!building.inputBuffer.counts.empty()) + std::vector inputs; + for (const std::pair& entry : building.inputBuffer.counts) { - text += tr("Input: "); - for (const std::pair& entry : building.inputBuffer.counts) + ItemChipRow::Entry chip; + chip.itemId = entry.first.id; + chip.countText = QString::number(entry.second); + + const int perCycle = findPerCycle(perCycleInputs, entry.first.id); + if (perCycle > 0) { - text += formatEntry(entry.first.id, entry.second, - findPerCycle(perCycleInputs, entry.first.id)); + chip.subLine = tr("/ %1 per cycle").arg(perCycle); } - text += "\n"; + inputs.push_back(chip); } // Output-side items are the buffered ones plus those still emerging onto the output @@ -72,24 +74,30 @@ void BufferSection::setBuffers(const Building& building, outputCounts[slot.item.type.id]++; } } - - // A configured building lists every item its cycle produces, so an output the player - // is waiting for shows as 0 rather than being absent. + // A configured building lists everything its cycle produces, so an output the player + // is waiting for reads as 0 rather than being absent. for (const std::pair& entry : perCycleOutputs) { outputCounts.emplace(entry.first, 0); } - if (!outputCounts.empty()) + std::vector outputs; + for (const std::pair& entry : outputCounts) { - text += tr("Output: "); - for (const std::pair& entry : outputCounts) - { - text += formatEntry(entry.first, entry.second, - findPerCycle(perCycleOutputs, entry.first)); - } + ItemChipRow::Entry chip; + chip.itemId = entry.first; + // Counted against the buffer's capacity, which is what production stops at + // (REQ-MAT-OUTPUT-BUFFER). + chip.countText = building.outputBuffer.capacity > 0 + ? tr("%1 / %2").arg(entry.second).arg(building.outputBuffer.capacity) + : QString::number(entry.second); + chip.subLine = QString::fromStdString(toDisplayName(entry.first)); + outputs.push_back(chip); } - m_label->setText(text.trimmed()); - setVisible(!text.trimmed().isEmpty()); + m_inputChips->setEntries(inputs); + m_outputChips->setEntries(outputs); + m_inputSection->setVisible(!inputs.empty()); + m_outputSection->setVisible(!outputs.empty()); + setVisible(!inputs.empty() || !outputs.empty()); } diff --git a/src/ui/selection/BufferSection.h b/src/ui/selection/BufferSection.h index 637e148..14cb833 100644 --- a/src/ui/selection/BufferSection.h +++ b/src/ui/selection/BufferSection.h @@ -5,20 +5,25 @@ #include -struct Building; -class QLabel; +#include "SelectionContext.h" -// The input and output buffer contents of one building (REQ-UI-SINGLE-SELECTION). +struct Building; +class ItemChipRow; +class SectionBox; + +// The input and output buffer contents of one building, each a captioned section of item +// chips (REQ-UI-SINGLE-SELECTION). // // Counting what is in the buffers is the same for every building type, so it happens // here; what a cycle consumes and produces is not, so the owning content supplies those -// per-cycle amounts. An item with no entry in the maps is shown without a denominator. +// per-cycle amounts. An item with no entry in the maps is shown without a denominator, +// and a section holding nothing is not shown at all. class BufferSection : public QWidget { Q_OBJECT public: - explicit BufferSection(QWidget* parent = nullptr); + explicit BufferSection(const SelectionContext& context, QWidget* parent = nullptr); // perCycleInputs and perCycleOutputs map an item id to the amount one production // cycle consumes or produces. Both may be empty, for a building that runs no cycle. @@ -27,5 +32,8 @@ public: const std::map& perCycleOutputs); private: - QLabel* m_label; + SectionBox* m_inputSection; + ItemChipRow* m_inputChips; + SectionBox* m_outputSection; + ItemChipRow* m_outputChips; }; diff --git a/src/ui/selection/BufferedBuildingContent.cpp b/src/ui/selection/BufferedBuildingContent.cpp index fe78122..de1786e 100644 --- a/src/ui/selection/BufferedBuildingContent.cpp +++ b/src/ui/selection/BufferedBuildingContent.cpp @@ -1,5 +1,7 @@ #include "BufferedBuildingContent.h" +#include + #include #include "Building.h" @@ -7,33 +9,76 @@ #include "BuildingTarget.h" #include "FactoryQueries.h" #include "ProductionSection.h" +#include "RecipeSummaryRow.h" +#include "SelectionNames.h" #include "Simulation.h" +namespace +{ + +std::vector toAmounts(const std::map& map) +{ + std::vector amounts; + amounts.reserve(map.size()); + for (const std::pair& entry : map) + { + amounts.push_back(RecipeSummaryRow::Amount{ entry.first, entry.second }); + } + return amounts; +} + +} // namespace + + BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context, BuildingId id, QWidget* parent) : SelectionContent(context, asConstructionSite(context, id), parent) , m_id(id) { - m_buffers = new BufferSection(this); + // The summary is configuration -- what the building will do -- so it sits with the + // selection control and is shown for a construction site too (REQ-UI-RECIPE-SUMMARY). + m_recipeSummary = new RecipeSummaryRow(context.itemIcons, this); + getConfigurationLayout()->addWidget(m_recipeSummary); + + m_buffers = new BufferSection(context, this); m_production = new ProductionSection(this); getRuntimeLayout()->addWidget(m_buffers); getRuntimeLayout()->addWidget(m_production); } -void BufferedBuildingContent::refreshRuntime() +void BufferedBuildingContent::refreshConfiguration() { - const Building* building = findBuilding(getContext().sim->getFactoryState(), m_id); - if (!building) + const BuildingTarget target = resolveBuildingTarget(getContext(), m_id); + if (!target.isValid()) { // Gone under the card. SelectionPanel rebuilds on the same refresh; this only // has to avoid reading it. return; } - setProductionStatusSlot(*building); + setBuildingIdentity(target.type, getBuildingTypeName(target.type)); - const CycleInfo cycle = getCycleInfo(*building); - m_buffers->setBuffers(*building, cycle.perCycleInputs, cycle.perCycleOutputs); - m_production->setProduction(cycle.runsProduction, *building, cycle.durationSeconds, + const CycleInfo cycle = getCycleInfo(target); + m_recipeSummary->setSummary(toAmounts(cycle.perCycleInputs), + toAmounts(cycle.perCycleOutputs), + cycle.durationSeconds); + + refreshControls(target); +} + +void BufferedBuildingContent::refreshRuntime() +{ + const BuildingTarget target = resolveBuildingTarget(getContext(), m_id); + if (!target.building) + { + return; + } + + setProductionStatusSlot(*target.building); + + const CycleInfo cycle = getCycleInfo(target); + m_buffers->setBuffers(*target.building, cycle.perCycleInputs, cycle.perCycleOutputs); + m_production->setProduction(cycle.runsProduction, *target.building, + cycle.durationSeconds, getContext().sim->getCurrentTick()); } diff --git a/src/ui/selection/BufferedBuildingContent.h b/src/ui/selection/BufferedBuildingContent.h index cc22450..081475f 100644 --- a/src/ui/selection/BufferedBuildingContent.h +++ b/src/ui/selection/BufferedBuildingContent.h @@ -6,15 +6,16 @@ #include "BuildingId.h" #include "SelectionContent.h" -struct Building; +struct BuildingTarget; class BufferSection; class ProductionSection; +class RecipeSummaryRow; // Shared body of the four cards that show one building with buffers -- the Miner and // Assembler, the Smelter and Reprocessing Plant, the Shipyard, and the Salvage Bay -// (REQ-UI-SELECTION-CONTENT). All four show the same header status, buffer contents and -// production progress; they differ only in what one production cycle costs and how long -// it takes, which is what the subclass supplies. +// (REQ-UI-SELECTION-CONTENT). All four show the same header identity and status, recipe +// summary, buffer contents and production progress; they differ only in what one +// production cycle costs and how long it takes, which is what the subclass supplies. // // This is implementation sharing, not a catalog entry: every concrete subclass is one // row of the content catalog. @@ -40,14 +41,22 @@ protected: BufferedBuildingContent(const SelectionContext& context, BuildingId id, QWidget* parent); - virtual CycleInfo getCycleInfo(const Building& building) const = 0; + // Called with a construction site's stored configuration too, so the summary of what + // the building will produce is shown before it is built (REQ-BLD-SITE-CONFIG). + virtual CycleInfo getCycleInfo(const BuildingTarget& target) const = 0; + + // The subclass's own configuration controls. The identity, the recipe summary, the + // buffers and the production progress are handled here. + virtual void refreshControls(const BuildingTarget& /*target*/) {} BuildingId getBuildingId() const { return m_id; } +private: + void refreshConfiguration() override; void refreshRuntime() override; -private: BuildingId m_id; + RecipeSummaryRow* m_recipeSummary; BufferSection* m_buffers; ProductionSection* m_production; }; diff --git a/src/ui/selection/CMakeLists.txt b/src/ui/selection/CMakeLists.txt index 1946450..03262a2 100644 --- a/src/ui/selection/CMakeLists.txt +++ b/src/ui/selection/CMakeLists.txt @@ -6,6 +6,15 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/SelectionNames.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingTarget.h ${CMAKE_CURRENT_SOURCE_DIR}/DebrisScrap.h + ${CMAKE_CURRENT_SOURCE_DIR}/StatRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/BarRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/SectionBox.h + ${CMAKE_CURRENT_SOURCE_DIR}/CountRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/StatusPill.h + ${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.h ${CMAKE_CURRENT_SOURCE_DIR}/BufferSection.h ${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.h @@ -33,6 +42,15 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/SelectionNames.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingTarget.cpp ${CMAKE_CURRENT_SOURCE_DIR}/DebrisScrap.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/StatRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BarRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/SectionBox.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/CountRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/StatusPill.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BufferSection.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.cpp diff --git a/src/ui/selection/CountRow.cpp b/src/ui/selection/CountRow.cpp new file mode 100644 index 0000000..6ae32a8 --- /dev/null +++ b/src/ui/selection/CountRow.cpp @@ -0,0 +1,29 @@ +#include "CountRow.h" + +#include +#include + +CountRow::CountRow(const QPixmap& symbol, const QString& name, int count, + QWidget* parent) + : QWidget(parent) +{ + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(6); + + m_symbolLabel = new QLabel(this); + m_symbolLabel->setPixmap(symbol); + m_symbolLabel->setVisible(!symbol.isNull()); + + m_nameLabel = new QLabel(name, this); + + // The same "x" notation the recipe tooltip and the header's aggregate count + // use (REQ-UI-MULTI-SELECTION). + m_countLabel = new QLabel(tr("x%1").arg(count), this); + m_countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + layout->addWidget(m_symbolLabel); + layout->addWidget(m_nameLabel); + layout->addStretch(1); + layout->addWidget(m_countLabel); +} diff --git a/src/ui/selection/CountRow.h b/src/ui/selection/CountRow.h new file mode 100644 index 0000000..5d52cb5 --- /dev/null +++ b/src/ui/selection/CountRow.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +class QLabel; + +// One " x" line of a count summary. The same part serves the +// building summary and the field summary, which count different things the same way +// (REQ-UI-MULTI-SELECTION, REQ-UI-FIELD-MULTI-SELECTION). +class CountRow : public QWidget +{ + Q_OBJECT + +public: + // An empty symbol leaves the icon off, for the kinds of object that have none. + CountRow(const QPixmap& symbol, const QString& name, int count, + QWidget* parent = nullptr); + +private: + QLabel* m_symbolLabel; + QLabel* m_nameLabel; + QLabel* m_countLabel; +}; diff --git a/src/ui/selection/DebrisContent.cpp b/src/ui/selection/DebrisContent.cpp index 9384075..852ddff 100644 --- a/src/ui/selection/DebrisContent.cpp +++ b/src/ui/selection/DebrisContent.cpp @@ -1,18 +1,19 @@ #include "DebrisContent.h" -#include #include #include "DebrisScrap.h" #include "Simulation.h" +#include "StatRow.h" DebrisContent::DebrisContent(const SelectionContext& context, const SelectionRequest& request, QWidget* parent) : SelectionContent(context, std::nullopt, parent) , m_debris(request.debris) { - m_scrapLabel = new QLabel(this); - getRuntimeLayout()->addWidget(m_scrapLabel); + m_scrapRow = new StatRow(tr("Scrap remaining"), this); + m_scrapRow->setValueEmphasized(true); + getRuntimeLayout()->addWidget(m_scrapRow); setIdentity(QPixmap(), tr("Debris")); if (m_debris.size() > 1) @@ -24,7 +25,8 @@ DebrisContent::DebrisContent(const SelectionContext& context, void DebrisContent::refreshRuntime() { // The value falls as the debris is collected and as pieces despawn - // (REQ-UI-DEBRIS-CLICK-SELECT), so it is re-summed rather than remembered. - m_scrapLabel->setText(tr("Scrap remaining: %1") - .arg(sumDebrisScrap(getContext().sim->getAdmin(), m_debris))); + // (REQ-UI-DEBRIS-CLICK-SELECT), so it is re-summed rather than remembered. With + // several pieces selected it is their sum (REQ-UI-SELECTION-AGGREGATE). + m_scrapRow->setValue(QString::number( + sumDebrisScrap(getContext().sim->getAdmin(), m_debris))); } diff --git a/src/ui/selection/DebrisContent.h b/src/ui/selection/DebrisContent.h index 6f1bd1a..00b7716 100644 --- a/src/ui/selection/DebrisContent.h +++ b/src/ui/selection/DebrisContent.h @@ -7,7 +7,7 @@ #include "SelectionContent.h" #include "SelectionContentFactory.h" -class QLabel; +class StatRow; // The card for selected debris (REQ-UI-DEBRIS-PANEL): the scrap still left in it. // @@ -27,5 +27,5 @@ protected: private: std::vector m_debris; - QLabel* m_scrapLabel; + StatRow* m_scrapRow; }; diff --git a/src/ui/selection/EmptyNote.cpp b/src/ui/selection/EmptyNote.cpp new file mode 100644 index 0000000..8afec14 --- /dev/null +++ b/src/ui/selection/EmptyNote.cpp @@ -0,0 +1,19 @@ +#include "EmptyNote.h" + +#include +#include + +EmptyNote::EmptyNote(const QString& text, QWidget* parent) + : QLabel(text, parent) +{ + setWordWrap(true); + + QFont noteFont = font(); + noteFont.setItalic(true); + setFont(noteFont); + + QPalette notePalette = palette(); + notePalette.setColor(QPalette::WindowText, + palette().color(QPalette::Disabled, QPalette::WindowText)); + setPalette(notePalette); +} diff --git a/src/ui/selection/EmptyNote.h b/src/ui/selection/EmptyNote.h new file mode 100644 index 0000000..69a52dd --- /dev/null +++ b/src/ui/selection/EmptyNote.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +// A dimmed aside explaining why a part of the card is not there yet -- a construction +// site's "no buffers until built" (REQ-UI-SELECTION-CARD). Styled apart from the card's +// values so it reads as an explanation rather than as data. +class EmptyNote : public QLabel +{ + Q_OBJECT + +public: + explicit EmptyNote(const QString& text, QWidget* parent = nullptr); +}; diff --git a/src/ui/selection/FieldMultiContent.cpp b/src/ui/selection/FieldMultiContent.cpp index 7a83df2..350764f 100644 --- a/src/ui/selection/FieldMultiContent.cpp +++ b/src/ui/selection/FieldMultiContent.cpp @@ -3,33 +3,31 @@ #include #include -#include -#include #include +#include "CountRow.h" #include "DebrisScrap.h" #include "DisplayName.h" #include "EntityAdmin.h" #include "FactionComponent.h" #include "ShipIdentityComponent.h" #include "Simulation.h" +#include "StatRow.h" #include "StationBodyComponent.h" FieldMultiContent::FieldMultiContent(const SelectionContext& context, const SelectionRequest& request, QWidget* parent) : SelectionContent(context, std::nullopt, parent) - , m_actors(request.actors) , m_debris(request.debris) + , m_scrapRow(nullptr) { - m_summaryLabel = new QLabel(this); - m_summaryLabel->setWordWrap(true); - getRuntimeLayout()->addWidget(m_summaryLabel); - setIdentity(QPixmap(), tr("Mixed selection")); - setCountSlot(static_cast(m_actors.size() + m_debris.size())); + setCountSlot(static_cast(request.actors.size() + request.debris.size())); + + buildSummary(request.actors); } -void FieldMultiContent::refreshRuntime() +void FieldMultiContent::buildSummary(const std::vector& actors) { EntityAdmin& admin = getContext().sim->getAdmin(); @@ -39,7 +37,7 @@ void FieldMultiContent::refreshRuntime() std::map counts; std::map labels; - for (entt::entity actor : m_actors) + for (entt::entity actor : actors) { if (!admin.isValid(actor)) { continue; } const bool isEnemy = admin.hasAll(actor) @@ -75,17 +73,33 @@ void FieldMultiContent::refreshRuntime() counts[key] += 1; } - QStringList lines; for (const QString& key : keys) { - lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]); + getRuntimeLayout()->addWidget( + new CountRow(QPixmap(), labels[key], counts[key], this)); } + if (!m_debris.empty()) { - lines << tr("Debris x %1").arg(static_cast(m_debris.size())); - // The scrap total follows the debris row rather than standing on its own, so it - // reads as belonging to it (REQ-UI-DEBRIS-PANEL). - lines << tr(" holding %1 scrap").arg(sumDebrisScrap(admin, m_debris)); + getRuntimeLayout()->addWidget(new CountRow( + QPixmap(), tr("Debris"), static_cast(m_debris.size()), this)); + + // Indented under the debris row, so the total reads as belonging to it + // (REQ-UI-DEBRIS-PANEL). + m_scrapRow = new StatRow(tr("holding"), this); + m_scrapRow->setIndented(true); + m_scrapRow->setValueEmphasized(true); + getRuntimeLayout()->addWidget(m_scrapRow); + } +} + +void FieldMultiContent::refreshRuntime() +{ + // The counts are fixed for a given selection -- an actor leaving it re-publishes the + // selection and rebuilds this card -- but the scrap falls as the debris is collected. + if (m_scrapRow) + { + m_scrapRow->setValue(tr("%1 scrap") + .arg(sumDebrisScrap(getContext().sim->getAdmin(), m_debris))); } - m_summaryLabel->setText(lines.join('\n')); } diff --git a/src/ui/selection/FieldMultiContent.h b/src/ui/selection/FieldMultiContent.h index 3f2f688..75b7e57 100644 --- a/src/ui/selection/FieldMultiContent.h +++ b/src/ui/selection/FieldMultiContent.h @@ -7,7 +7,7 @@ #include "SelectionContent.h" #include "SelectionContentFactory.h" -class QLabel; +class StatRow; // The count summary for a field selection holding more than one object that does not // aggregate (REQ-UI-FIELD-MULTI-SELECTION): several actors, or actors together with @@ -24,7 +24,10 @@ protected: void refreshRuntime() override; private: - std::vector m_actors; + void buildSummary(const std::vector& actors); + std::vector m_debris; - QLabel* m_summaryLabel; + // Null unless debris is part of the selection; the only value here that changes + // while the selection stands. + StatRow* m_scrapRow; }; diff --git a/src/ui/selection/HqContent.cpp b/src/ui/selection/HqContent.cpp index 9b7516a..0753012 100644 --- a/src/ui/selection/HqContent.cpp +++ b/src/ui/selection/HqContent.cpp @@ -1,11 +1,16 @@ #include "HqContent.h" -#include +#include + #include +#include "BarRow.h" #include "EntityAdmin.h" #include "HealthComponent.h" #include "HqProxyComponent.h" +#include "IconCaption.h" +#include "ItemChipRow.h" +#include "SectionBox.h" #include "SelectionNames.h" #include "Simulation.h" @@ -15,18 +20,28 @@ HqContent::HqContent(const SelectionContext& context, const SelectionRequest& re // (REQ-BLD-DECONSTRUCT), so it is never a construction site. : SelectionContent(context, std::nullopt, parent) { - m_stockLabel = new QLabel(this); - m_hpLabel = new QLabel(this); - getRuntimeLayout()->addWidget(m_stockLabel); - getRuntimeLayout()->addWidget(m_hpLabel); + m_stockSection = new SectionBox(tr("Building blocks"), this); + m_stockChips = new ItemChipRow(context.itemIcons, m_stockSection); + m_stockSection->getContentLayout()->addWidget(m_stockChips); + + m_hpBar = new BarRow(tr("HP"), this); + + getRuntimeLayout()->addWidget(m_stockSection); + getRuntimeLayout()->addWidget(m_hpBar); setBuildingIdentity(BuildingType::Hq, getBuildingTypeName(BuildingType::Hq)); } void HqContent::refreshRuntime() { - m_stockLabel->setText( - tr("Building blocks: %1").arg(getContext().sim->getBuildingBlocksStock())); + // Not a buffer: blocks delivered by belt go straight into the global stock + // (REQ-HQ-BELT-INPUT). Showing it here is what tells the player to route them here + // (REQ-UI-HQ-PANEL). + ItemChipRow::Entry stock; + stock.itemId = kBlockItemId; + stock.countText = QString::number(getContext().sim->getBuildingBlocksStock()); + stock.subLine = tr("in stock"); + m_stockChips->setEntries(std::vector{ stock }); // The HQ's health lives on its proxy entity, not on the building // (REQ-HQ-STATS, REQ-UI-HP-BARS). @@ -35,7 +50,10 @@ void HqContent::refreshRuntime() [this](entt::entity /*entity*/, const HqProxyComponent& /*proxy*/, const HealthComponent& health) { - m_hpLabel->setText(tr("HP: %1 / %2") + const double fraction = (health.maxHp > 0.0f) + ? static_cast(health.hp) / health.maxHp + : 0.0; + m_hpBar->setValue(fraction, tr("%1 / %2") .arg(static_cast(health.hp + 0.5f)) .arg(static_cast(health.maxHp + 0.5f))); }); diff --git a/src/ui/selection/HqContent.h b/src/ui/selection/HqContent.h index 212ba15..56f912f 100644 --- a/src/ui/selection/HqContent.h +++ b/src/ui/selection/HqContent.h @@ -3,7 +3,9 @@ #include "SelectionContent.h" #include "SelectionContentFactory.h" -class QLabel; +class BarRow; +class ItemChipRow; +class SectionBox; // The card for the HQ (REQ-UI-HQ-PANEL): the global building blocks stock and the HQ's // HP. It has no configuration group and no status indicator. @@ -24,6 +26,7 @@ protected: void refreshRuntime() override; private: - QLabel* m_stockLabel; - QLabel* m_hpLabel; + SectionBox* m_stockSection; + ItemChipRow* m_stockChips; + BarRow* m_hpBar; }; diff --git a/src/ui/selection/ItemChip.cpp b/src/ui/selection/ItemChip.cpp new file mode 100644 index 0000000..49f128b --- /dev/null +++ b/src/ui/selection/ItemChip.cpp @@ -0,0 +1,72 @@ +#include "ItemChip.h" + +#include +#include +#include +#include +#include + +namespace +{ + +// Point-size rise of the count and drop of the sub-line, relative to the card's text. +const int kCountSizeRisePt = 2; +const int kSubLineSizeDropPt = 1; + +} // namespace + + +ItemChip::ItemChip(const QPixmap& icon, QWidget* parent) + : QWidget(parent) +{ + // Its own boxed chrome, drawn with palette colors like the rest of the panel's + // furniture rather than from visuals.toml, which is for world rendering. + setAttribute(Qt::WA_StyledBackground, true); + setStyleSheet(QStringLiteral( + "ItemChip { border: 1px solid palette(mid); border-radius: 3px; }")); + + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(4, 3, 4, 3); + layout->setSpacing(6); + + m_iconLabel = new QLabel(this); + m_iconLabel->setPixmap(icon); + m_iconLabel->setVisible(!icon.isNull()); + + QWidget* text = new QWidget(this); + QVBoxLayout* textLayout = new QVBoxLayout(text); + textLayout->setContentsMargins(0, 0, 0, 0); + textLayout->setSpacing(0); + + m_countLabel = new QLabel(text); + QFont countFont = m_countLabel->font(); + countFont.setPointSize(countFont.pointSize() + kCountSizeRisePt); + m_countLabel->setFont(countFont); + + m_subLineLabel = new QLabel(text); + QFont subLineFont = m_subLineLabel->font(); + subLineFont.setPointSize(qMax(1, subLineFont.pointSize() - kSubLineSizeDropPt)); + m_subLineLabel->setFont(subLineFont); + QPalette subLinePalette = m_subLineLabel->palette(); + subLinePalette.setColor(QPalette::WindowText, + palette().color(QPalette::Disabled, QPalette::WindowText)); + m_subLineLabel->setPalette(subLinePalette); + m_subLineLabel->hide(); + + textLayout->addWidget(m_countLabel); + textLayout->addWidget(m_subLineLabel); + + layout->addWidget(m_iconLabel); + layout->addWidget(text); +} + +void ItemChip::setCount(const QString& count) +{ + m_countLabel->setText(count); +} + +void ItemChip::setSubLine(const QString& subLine) +{ + m_subLineLabel->setText(subLine); + m_subLineLabel->setVisible(!subLine.isEmpty()); +} diff --git a/src/ui/selection/ItemChip.h b/src/ui/selection/ItemChip.h new file mode 100644 index 0000000..1bba711 --- /dev/null +++ b/src/ui/selection/ItemChip.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +class QLabel; + +// One buffered item: its icon, its count in a larger type, and a sub-line beneath the +// count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities +// rather than as a run of text. +class ItemChip : public QWidget +{ + Q_OBJECT + +public: + // An empty icon leaves the icon off and the chip laid out around the text alone. + ItemChip(const QPixmap& icon, QWidget* parent = nullptr); + + void setCount(const QString& count); + // Left empty for an item with nothing to say beneath its count. + void setSubLine(const QString& subLine); + +private: + QLabel* m_iconLabel; + QLabel* m_countLabel; + QLabel* m_subLineLabel; +}; diff --git a/src/ui/selection/ItemChipRow.cpp b/src/ui/selection/ItemChipRow.cpp new file mode 100644 index 0000000..bccf8a8 --- /dev/null +++ b/src/ui/selection/ItemChipRow.cpp @@ -0,0 +1,78 @@ +#include "ItemChipRow.h" + +#include + +#include "ItemChip.h" +#include "ItemIconCache.h" + +namespace +{ + +// Size the item icon is drawn at inside a chip, in device-independent pixels. +const int kChipIconSizePx = 18; + +// Chips per row. Two fit the panel's capped width side by side; a third would force the +// counts to shrink. +const int kChipsPerRow = 2; + +} // namespace + + +ItemChipRow::ItemChipRow(ItemIconCache* itemIcons, QWidget* parent) + : QWidget(parent) + , m_itemIcons(itemIcons) +{ + m_layout = new QGridLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + m_layout->setSpacing(4); +} + +void ItemChipRow::setEntries(const std::vector& entries) +{ + std::vector itemIds; + itemIds.reserve(entries.size()); + for (const Entry& entry : entries) + { + itemIds.push_back(entry.itemId); + } + + // Only the set of items changing is a structural change; the counts change every + // tick and must not cost a widget rebuild. + if (itemIds != m_itemIds) + { + m_itemIds = std::move(itemIds); + rebuildChips(entries); + } + + for (std::size_t index = 0; index < entries.size(); ++index) + { + m_chips[index]->setCount(entries[index].countText); + m_chips[index]->setSubLine(entries[index].subLine); + } + setVisible(!entries.empty()); +} + +void ItemChipRow::rebuildChips(const std::vector& entries) +{ + for (ItemChip* chip : m_chips) + { + m_layout->removeWidget(chip); + chip->deleteLater(); + } + m_chips.clear(); + + for (std::size_t index = 0; index < entries.size(); ++index) + { + const std::string& itemId = entries[index].itemId; + // A missing icon file is not an error (REQ-UI-ITEM-ICON): the chip is then laid + // out around its count alone. + const QPixmap icon = m_itemIcons->hasIcon(itemId) + ? m_itemIcons->getPixmap(itemId, kChipIconSizePx) + : QPixmap(); + + ItemChip* chip = new ItemChip(icon, this); + m_layout->addWidget(chip, static_cast(index) / kChipsPerRow, + static_cast(index) % kChipsPerRow); + m_chips.push_back(chip); + } +} diff --git a/src/ui/selection/ItemChipRow.h b/src/ui/selection/ItemChipRow.h new file mode 100644 index 0000000..e1026ac --- /dev/null +++ b/src/ui/selection/ItemChipRow.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include +#include + +class ItemChip; +class ItemIconCache; +class QGridLayout; + +// The buffered items of one building, each as a chip carrying the item's icon, its +// current count and a sub-line (REQ-UI-SINGLE-SELECTION): the per-cycle amount for an +// input, the item's name for an output. +// +// The chips are rebuilt only when the set of items changes, not when their counts do, so +// a refresh at tick rate updates numbers instead of churning widgets. +class ItemChipRow : public QWidget +{ + Q_OBJECT + +public: + struct Entry + { + std::string itemId; + QString countText; + QString subLine; + }; + + // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with + // no icon file simply shows no icon, which is not an error. Not owned. + explicit ItemChipRow(ItemIconCache* itemIcons, QWidget* parent = nullptr); + + void setEntries(const std::vector& entries); + +private: + void rebuildChips(const std::vector& entries); + + ItemIconCache* m_itemIcons; + QGridLayout* m_layout; + std::vector m_itemIds; // what the chips currently stand for + std::vector m_chips; +}; diff --git a/src/ui/selection/MultiBuildingContent.cpp b/src/ui/selection/MultiBuildingContent.cpp index 2707939..dc892a2 100644 --- a/src/ui/selection/MultiBuildingContent.cpp +++ b/src/ui/selection/MultiBuildingContent.cpp @@ -2,16 +2,26 @@ #include -#include -#include #include #include "Building.h" +#include "BuildingIconCache.h" #include "ClearBeltControl.h" +#include "CountRow.h" #include "FactoryQueries.h" #include "GameConfig.h" #include "SelectionNames.h" #include "Simulation.h" +#include "StatRow.h" + +namespace +{ + +// Size the type symbol is drawn at on a count row, matching the card header's chip. +const int kCountSymbolSizePx = 20; + +} // namespace + MultiBuildingContent::MultiBuildingContent(const SelectionContext& context, const SelectionRequest& request, @@ -19,10 +29,11 @@ MultiBuildingContent::MultiBuildingContent(const SelectionContext& context, : SelectionContent(context, std::nullopt, parent) , m_ids(request.buildings) { - m_countsLabel = new QLabel(this); - m_totalCostLabel = new QLabel(this); - getRuntimeLayout()->addWidget(m_countsLabel); - getRuntimeLayout()->addWidget(m_totalCostLabel); + // The header names the size of the selection instead of an object + // (REQ-UI-MULTI-SELECTION). + setIdentity(QPixmap(), tr("%1 buildings").arg(static_cast(m_ids.size()))); + + buildSummary(); // A selection holding any belt-subsystem tile can still be cleared as a whole // (REQ-UI-BELT-CLEAR), even though the mixture is what kept it from aggregating. @@ -40,12 +51,6 @@ MultiBuildingContent::MultiBuildingContent(const SelectionContext& context, { getRuntimeLayout()->addWidget(new ClearBeltControl(context, m_ids, this)); } - - // The header names the size of the selection instead of an object - // (REQ-UI-MULTI-SELECTION). - setIdentity(QPixmap(), tr("%1 buildings").arg(static_cast(m_ids.size()))); - - buildSummary(); } void MultiBuildingContent::buildSummary() @@ -68,11 +73,13 @@ void MultiBuildingContent::buildSummary() } } - QStringList lines; int totalCost = 0; for (const std::pair& entry : counts) { - lines << tr("%1 x %2").arg(getBuildingTypeName(entry.first)).arg(entry.second); + getRuntimeLayout()->addWidget(new CountRow( + getContext().buildingIcons->getChip(buildingTypeId(entry.first), + kCountSymbolSizePx), + getBuildingTypeName(entry.first), entry.second, this)); // Only player-placeable buildings count toward the total; the HQ and defence // stations are excluded (REQ-UI-MULTI-SELECTION). A construction site counts at @@ -85,6 +92,8 @@ void MultiBuildingContent::buildSummary() } } - m_countsLabel->setText(lines.join('\n')); - m_totalCostLabel->setText(tr("Total: %1 Building Blocks").arg(totalCost)); + StatRow* totalRow = new StatRow(tr("Total cost"), this); + totalRow->setValue(QString::number(totalCost)); + totalRow->setValueEmphasized(true); + getRuntimeLayout()->addWidget(totalRow); } diff --git a/src/ui/selection/MultiBuildingContent.h b/src/ui/selection/MultiBuildingContent.h index 9303964..8fb12e4 100644 --- a/src/ui/selection/MultiBuildingContent.h +++ b/src/ui/selection/MultiBuildingContent.h @@ -6,7 +6,6 @@ #include "SelectionContent.h" #include "SelectionContentFactory.h" -class QLabel; // The count summary for several selected buildings that do not aggregate // (REQ-UI-MULTI-SELECTION, REQ-UI-SELECTION-AGGREGATE): how many of each type, and the @@ -29,6 +28,4 @@ private: void buildSummary(); std::vector m_ids; - QLabel* m_countsLabel; - QLabel* m_totalCostLabel; }; diff --git a/src/ui/selection/ProductionSection.cpp b/src/ui/selection/ProductionSection.cpp index ea66b33..22eb35f 100644 --- a/src/ui/selection/ProductionSection.cpp +++ b/src/ui/selection/ProductionSection.cpp @@ -2,10 +2,11 @@ #include -#include #include +#include "BarRow.h" #include "Building.h" +#include "SectionBox.h" ProductionSection::ProductionSection(QWidget* parent) : QWidget(parent) @@ -14,40 +15,34 @@ ProductionSection::ProductionSection(QWidget* parent) layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); - m_label = new QLabel(this); - layout->addWidget(m_label); + m_section = new SectionBox(tr("Production"), this); + m_bar = new BarRow(QString(), m_section); + m_section->getContentLayout()->addWidget(m_bar); + layout->addWidget(m_section); } void ProductionSection::setProduction(bool runsProduction, const Building& building, double durationSeconds, Tick currentTick) { - // Nothing selected to produce means neither a cycle time nor a progress indicator + // Nothing selected to produce means no progress indicator at all // (REQ-UI-PRODUCTION-PROGRESS). if (!runsProduction) { hide(); return; } - - QString text; - if (durationSeconds > 0.0) - { - text = tr("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1); - } - if (durationSeconds > 0.0 && building.production.has_value()) - { - const Tick cycleTicks = secondsToTicks(durationSeconds); - const Tick elapsed = - currentTick - (building.production->completesAt - cycleTicks); - const int percent = static_cast( - std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks); - text += tr("Progress: %1%").arg(percent); - } - else - { - text += tr("Progress: idle"); - } - - m_label->setText(text); show(); + + // No running cycle, or none whose length is known: the bar is empty and reads idle. + if (durationSeconds <= 0.0 || !building.production.has_value()) + { + m_bar->setValue(0.0, tr("idle")); + return; + } + + const Tick cycleTicks = secondsToTicks(durationSeconds); + const Tick elapsed = currentTick - (building.production->completesAt - cycleTicks); + const Tick clamped = std::max(Tick(0), std::min(cycleTicks, elapsed)); + const int percent = static_cast(clamped * 100 / cycleTicks); + m_bar->setValue(static_cast(clamped) / cycleTicks, tr("%1%").arg(percent)); } diff --git a/src/ui/selection/ProductionSection.h b/src/ui/selection/ProductionSection.h index 1c4a0fb..f54d932 100644 --- a/src/ui/selection/ProductionSection.h +++ b/src/ui/selection/ProductionSection.h @@ -5,7 +5,8 @@ #include "Tick.h" struct Building; -class QLabel; +class BarRow; +class SectionBox; // The cycle time and production progress of one building (REQ-UI-PRODUCTION-PROGRESS). // @@ -29,5 +30,6 @@ public: double durationSeconds, Tick currentTick); private: - QLabel* m_label; + SectionBox* m_section; + BarRow* m_bar; }; diff --git a/src/ui/selection/RecipeProductionContent.cpp b/src/ui/selection/RecipeProductionContent.cpp index 53049e0..520f02d 100644 --- a/src/ui/selection/RecipeProductionContent.cpp +++ b/src/ui/selection/RecipeProductionContent.cpp @@ -2,11 +2,9 @@ #include -#include "Building.h" #include "BuildingTarget.h" #include "GameConfig.h" #include "RecipeSelectionControl.h" -#include "SelectionNames.h" RecipeProductionContent::RecipeProductionContent(const SelectionContext& context, const SelectionRequest& request, @@ -15,31 +13,25 @@ RecipeProductionContent::RecipeProductionContent(const SelectionContext& context { const BuildingTarget target = resolveBuildingTarget(context, getBuildingId()); - // The control is shown for a construction site too: a site is configured exactly - // like the building it will become (REQ-BLD-SITE-CONFIG). + // Shown for a construction site too: a site is configured exactly like the building + // it will become (REQ-BLD-SITE-CONFIG). m_recipeControl = new RecipeSelectionControl(context, getBuildingId(), target.type, this); - getConfigurationLayout()->addWidget(m_recipeControl); + getConfigurationLayout()->insertWidget(0, m_recipeControl); } -void RecipeProductionContent::refreshConfiguration() +void RecipeProductionContent::refreshControls(const BuildingTarget& target) { - const BuildingTarget target = resolveBuildingTarget(getContext(), getBuildingId()); - if (!target.isValid()) - { - return; - } - setBuildingIdentity(target.type, getBuildingTypeName(target.type)); m_recipeControl->setRecipeId(target.recipeId); } BufferedBuildingContent::CycleInfo RecipeProductionContent::getCycleInfo( - const Building& building) const + const BuildingTarget& target) const { CycleInfo info; - const RecipeDef* recipe = building.recipeId.empty() + const RecipeDef* recipe = target.recipeId.empty() ? nullptr - : getContext().config->recipes.findRecipeDef(building.recipeId, building.type); + : getContext().config->recipes.findRecipeDef(target.recipeId, target.type); if (!recipe) { return info; diff --git a/src/ui/selection/RecipeProductionContent.h b/src/ui/selection/RecipeProductionContent.h index 87ea57d..ce1bb56 100644 --- a/src/ui/selection/RecipeProductionContent.h +++ b/src/ui/selection/RecipeProductionContent.h @@ -18,8 +18,8 @@ public: const SelectionRequest& request, QWidget* parent = nullptr); protected: - void refreshConfiguration() override; - CycleInfo getCycleInfo(const Building& building) const override; + void refreshControls(const BuildingTarget& target) override; + CycleInfo getCycleInfo(const BuildingTarget& target) const override; private: RecipeSelectionControl* m_recipeControl; diff --git a/src/ui/selection/RecipeSummaryRow.cpp b/src/ui/selection/RecipeSummaryRow.cpp new file mode 100644 index 0000000..37e3369 --- /dev/null +++ b/src/ui/selection/RecipeSummaryRow.cpp @@ -0,0 +1,108 @@ +#include "RecipeSummaryRow.h" + +#include +#include +#include +#include + +#include "ItemIconCache.h" + +namespace +{ + +// Size the item icons are drawn at on the summary line, in device-independent pixels. +const int kSummaryIconSizePx = 14; + +} // namespace + + +RecipeSummaryRow::RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent) + : QWidget(parent) + , m_itemIcons(itemIcons) +{ + m_layout = new QHBoxLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + m_layout->setSpacing(4); + + hide(); +} + +void RecipeSummaryRow::setSummary(const std::vector& inputs, + const std::vector& outputs, + double durationSeconds) +{ + if (outputs.empty() && inputs.empty()) + { + // Forgotten as well as hidden, so re-selecting the same recipe later is seen as + // a change and shows the row again. + m_inputs.clear(); + m_outputs.clear(); + m_durationSeconds = -1.0; + hide(); + return; + } + if (inputs == m_inputs && outputs == m_outputs + && durationSeconds == m_durationSeconds) + { + return; + } + + m_inputs = inputs; + m_outputs = outputs; + m_durationSeconds = durationSeconds; + rebuild(inputs, outputs, durationSeconds); + show(); +} + +void RecipeSummaryRow::rebuild(const std::vector& inputs, + const std::vector& outputs, + double durationSeconds) +{ + while (QLayoutItem* item = m_layout->takeAt(0)) + { + if (item->widget()) + { + item->widget()->deleteLater(); + } + delete item; + } + + addAmounts(inputs); + if (!inputs.empty()) + { + const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW + m_layout->addWidget(new QLabel(QString(rightArrow), this)); + } + addAmounts(outputs); + + if (durationSeconds > 0.0) + { + const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT + m_layout->addWidget(new QLabel( + QStringLiteral("%1 %2").arg(middleDot).arg( + tr("%1 s").arg(durationSeconds, 0, 'f', 1)), this)); + } + m_layout->addStretch(1); +} + +void RecipeSummaryRow::addAmounts(const std::vector& amounts) +{ + for (const Amount& entry : amounts) + { + // A missing icon file is not an error (REQ-UI-ITEM-ICON): the item's id then + // stands in for its icon. + if (m_itemIcons->hasIcon(entry.itemId)) + { + QLabel* iconLabel = new QLabel(this); + iconLabel->setPixmap( + m_itemIcons->getPixmap(entry.itemId, kSummaryIconSizePx)); + m_layout->addWidget(iconLabel); + } + else + { + m_layout->addWidget( + new QLabel(QString::fromStdString(entry.itemId), this)); + } + m_layout->addWidget(new QLabel(QString::number(entry.amount), this)); + } +} diff --git a/src/ui/selection/RecipeSummaryRow.h b/src/ui/selection/RecipeSummaryRow.h new file mode 100644 index 0000000..9d33105 --- /dev/null +++ b/src/ui/selection/RecipeSummaryRow.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include + +class ItemIconCache; +class QHBoxLayout; + +// What one production cycle does, on a line: each input item with its per-cycle amount, +// an arrow, each output with its amount, and the cycle time +// (REQ-UI-RECIPE-SUMMARY). It restates the selected recipe without the player having to +// open the selection dialog, and it is the panel's only display of the cycle time. +class RecipeSummaryRow : public QWidget +{ + Q_OBJECT + +public: + struct Amount + { + std::string itemId; + int amount = 0; + + bool operator==(const Amount& other) const + { + return itemId == other.itemId && amount == other.amount; + } + }; + + // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with + // no icon file falls back to its id. Not owned. + explicit RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent = nullptr); + + // Hides the row when there is nothing selected to produce, which is how a building + // with no recipe shows no summary at all. + void setSummary(const std::vector& inputs, + const std::vector& outputs, double durationSeconds); + +private: + void rebuild(const std::vector& inputs, const std::vector& outputs, + double durationSeconds); + void addAmounts(const std::vector& amounts); + + ItemIconCache* m_itemIcons; + QHBoxLayout* m_layout; + // What the row currently shows, so a refresh at tick rate rebuilds it only when the + // recipe actually changed. + std::vector m_inputs; + std::vector m_outputs; + double m_durationSeconds = -1.0; +}; diff --git a/src/ui/selection/SectionBox.cpp b/src/ui/selection/SectionBox.cpp new file mode 100644 index 0000000..d31e9b9 --- /dev/null +++ b/src/ui/selection/SectionBox.cpp @@ -0,0 +1,50 @@ +#include "SectionBox.h" + +#include +#include +#include +#include + +namespace +{ + +// Point-size drop of the caption relative to the card's text, so it reads as a heading +// without competing with the values beneath it. +const int kCaptionSizeDropPt = 1; + +} // namespace + + +SectionBox::SectionBox(const QString& caption, QWidget* parent) + : QWidget(parent) +{ + QVBoxLayout* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(2); + + m_captionLabel = new QLabel(caption.toUpper(), this); + QFont captionFont = m_captionLabel->font(); + captionFont.setPointSize(qMax(1, captionFont.pointSize() - kCaptionSizeDropPt)); + captionFont.setBold(true); + m_captionLabel->setFont(captionFont); + + // Dimmed to the palette's disabled text, so the caption sits behind its content. + QPalette captionPalette = m_captionLabel->palette(); + captionPalette.setColor(QPalette::WindowText, + palette().color(QPalette::Disabled, QPalette::WindowText)); + m_captionLabel->setPalette(captionPalette); + m_captionLabel->setVisible(!caption.isEmpty()); + + QWidget* content = new QWidget(this); + m_contentLayout = new QVBoxLayout(content); + m_contentLayout->setContentsMargins(0, 0, 0, 0); + m_contentLayout->setSpacing(2); + + layout->addWidget(m_captionLabel); + layout->addWidget(content); +} + +QVBoxLayout* SectionBox::getContentLayout() +{ + return m_contentLayout; +} diff --git a/src/ui/selection/SectionBox.h b/src/ui/selection/SectionBox.h new file mode 100644 index 0000000..854357e --- /dev/null +++ b/src/ui/selection/SectionBox.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +class QLabel; +class QVBoxLayout; + +// A captioned group of parts within a card's configuration or runtime group +// (REQ-UI-SELECTION-CARD): a small heading over whatever the owner puts inside it. +// +// A section and its caption are shown or hidden as one, which is what lets a card list +// its sections unconditionally and simply hide the ones with nothing in them -- a Miner +// consumes nothing, so its input buffer section is never shown. +class SectionBox : public QWidget +{ + Q_OBJECT + +public: + explicit SectionBox(const QString& caption, QWidget* parent = nullptr); + + // The layout to add this section's parts to. + QVBoxLayout* getContentLayout(); + +private: + QLabel* m_captionLabel; + QVBoxLayout* m_contentLayout; +}; diff --git a/src/ui/selection/SelectionContent.cpp b/src/ui/selection/SelectionContent.cpp index 8c082a1..1e32ec9 100644 --- a/src/ui/selection/SelectionContent.cpp +++ b/src/ui/selection/SelectionContent.cpp @@ -4,18 +4,19 @@ #include #include -#include #include #include -#include -#include #include +#include "BarRow.h" #include "BuildingIconCache.h" +#include "EmptyNote.h" #include "FactoryQueries.h" #include "GameConfig.h" #include "ProductionRules.h" +#include "SectionBox.h" #include "Simulation.h" +#include "StatusPill.h" #include "Tick.h" #include "VisualsConfig.h" @@ -27,31 +28,10 @@ namespace // the whole button face. const int kSymbolSizePx = 20; -// Diameter of the status dot beside the header's right-slot caption -// (REQ-UI-SELECTION-STATUS). -const int kStatusDotSizePx = 8; - // Spacing inside the card and between the header's elements. const int kCardSpacingPx = 6; const int kHeaderSpacingPx = 6; -QPixmap renderStatusDot(const QColor& fill, const QColor& outline) -{ - const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0; - QPixmap pixmap(static_cast(kStatusDotSizePx * dpr), - static_cast(kStatusDotSizePx * dpr)); - pixmap.setDevicePixelRatio(dpr); - pixmap.fill(Qt::transparent); - - QPainter painter(&pixmap); - painter.setRenderHint(QPainter::Antialiasing, true); - painter.setPen(outline); - painter.setBrush(fill); - // Inset by half the pen width so the outline stays inside the pixmap. - painter.drawEllipse(QRectF(0.5, 0.5, kStatusDotSizePx - 1.0, kStatusDotSizePx - 1.0)); - return pixmap; -} - } // namespace @@ -61,7 +41,8 @@ SelectionContent::SelectionContent(const SelectionContext& context, : QWidget(parent) , m_context(context) , m_siteId(constructionSiteId) - , m_constructionLabel(nullptr) + , m_constructionSection(nullptr) + , m_constructionBar(nullptr) { QVBoxLayout* cardLayout = new QVBoxLayout(this); cardLayout->setContentsMargins(0, 0, 0, 0); @@ -82,17 +63,12 @@ SelectionContent::SelectionContent(const SelectionContext& context, nameFont.setBold(true); m_nameLabel->setFont(nameFont); - m_slotDot = new QLabel(header); - m_slotDot->hide(); - - m_slotLabel = new QLabel(header); - m_slotLabel->hide(); + m_statusPill = new StatusPill(header); headerLayout->addWidget(m_symbolLabel); headerLayout->addWidget(m_nameLabel); headerLayout->addStretch(1); - headerLayout->addWidget(m_slotDot); - headerLayout->addWidget(m_slotLabel); + headerLayout->addWidget(m_statusPill); cardLayout->addWidget(header); m_configurationGroup = new QWidget(this); @@ -114,8 +90,13 @@ SelectionContent::SelectionContent(const SelectionContext& context, // takes its place (REQ-BLD-SITE-CONFIG, REQ-UI-SELECTION-CARD). The subclass // still fills the group -- it just never becomes visible. m_runtimeGroup->hide(); - m_constructionLabel = new QLabel(this); - cardLayout->addWidget(m_constructionLabel); + + m_constructionSection = new SectionBox(tr("Construction"), this); + m_constructionBar = new BarRow(QString(), m_constructionSection); + m_constructionSection->getContentLayout()->addWidget(m_constructionBar); + m_constructionSection->getContentLayout()->addWidget( + new EmptyNote(tr("No buffers until built"), m_constructionSection)); + cardLayout->addWidget(m_constructionSection); setSlot(QColor(), tr("constructing")); } @@ -159,18 +140,7 @@ void SelectionContent::setBuildingIdentity(BuildingType type, const QString& nam void SelectionContent::setSlot(const QColor& dotColor, const QString& caption) { - if (dotColor.isValid()) - { - m_slotDot->setPixmap( - renderStatusDot(dotColor, m_context.visuals->statusLight.outline)); - m_slotDot->show(); - } - else - { - m_slotDot->hide(); - } - m_slotLabel->setText(caption); - m_slotLabel->setVisible(!caption.isEmpty()); + m_statusPill->setStatus(dotColor, m_context.visuals->statusLight.outline, caption); } void SelectionContent::setCountSlot(int count) @@ -227,28 +197,26 @@ void SelectionContent::refreshConstruction() return; } - QString progress; if (site->completesAt == 0) { - progress = tr("Queued"); + // Placed but not yet at the head of the construction queue (REQ-BLD-QUEUE), so + // there is no progress to show yet. + m_constructionBar->setValue(0.0, tr("Queued")); + return; } - else + + const BuildingDef* def = m_context.config->buildings.findBuildingDef(site->type); + if (!def || def->constructionTimeSeconds <= 0) { - const BuildingDef* def = - m_context.config->buildings.findBuildingDef(site->type); - if (def && def->constructionTimeSeconds > 0) - { - const Tick duration = secondsToTicks(def->constructionTimeSeconds); - const Tick elapsed = - m_context.sim->getCurrentTick() - (site->completesAt - duration); - const int percent = static_cast( - std::max(Tick(0), std::min(duration, elapsed)) * 100 / duration); - progress = tr("Construction: %1%").arg(percent); - } - else - { - progress = tr("Building..."); - } + m_constructionBar->setValue(0.0, tr("Building...")); + return; } - m_constructionLabel->setText(progress); + + const Tick duration = secondsToTicks(def->constructionTimeSeconds); + const Tick elapsed = + m_context.sim->getCurrentTick() - (site->completesAt - duration); + const Tick clamped = std::max(Tick(0), std::min(duration, elapsed)); + const int percent = static_cast(clamped * 100 / duration); + m_constructionBar->setValue(static_cast(clamped) / duration, + tr("%1%").arg(percent)); } diff --git a/src/ui/selection/SelectionContent.h b/src/ui/selection/SelectionContent.h index a7b6bbb..6d8139a 100644 --- a/src/ui/selection/SelectionContent.h +++ b/src/ui/selection/SelectionContent.h @@ -12,8 +12,11 @@ #include "SelectionContext.h" struct Building; +class BarRow; class QLabel; class QVBoxLayout; +class SectionBox; +class StatusPill; // One card of the selection panel: the content shown for a particular kind of selection // (REQ-UI-SELECTION-CARD). Every content is this base plus the parts its constructor @@ -88,14 +91,14 @@ private: // Set while this card shows a construction site rather than a finished object. std::optional m_siteId; - QLabel* m_symbolLabel; - QLabel* m_nameLabel; - QLabel* m_slotDot; - QLabel* m_slotLabel; + QLabel* m_symbolLabel; + QLabel* m_nameLabel; + StatusPill* m_statusPill; QWidget* m_configurationGroup; QWidget* m_runtimeGroup; - // Replaces the runtime group while this card shows a construction site; null + // Replaces the runtime group while this card shows a construction site; both null // otherwise. - QLabel* m_constructionLabel; + SectionBox* m_constructionSection; + BarRow* m_constructionBar; }; diff --git a/src/ui/selection/SelectionNames.cpp b/src/ui/selection/SelectionNames.cpp index ec30ff4..c41589a 100644 --- a/src/ui/selection/SelectionNames.cpp +++ b/src/ui/selection/SelectionNames.cpp @@ -14,3 +14,22 @@ QString getBuildingTypeName(BuildingType type) } return QString::fromStdString(toDisplayName(buildingTypeId(type))); } + +QString getBehaviorLabel(BehaviorKind kind) +{ + // Only the winning behavior is named; the salvage and repair cycles that run + // regardless of it are not behaviors here (REQ-UI-SHIP-BEHAVIOR). + switch (kind) + { + case BehaviorKind::Retreat: return QObject::tr("Retreating"); + case BehaviorKind::Attack: return QObject::tr("Engaging"); + case BehaviorKind::SalvageScrap: + case BehaviorKind::DeliverScrap: return QObject::tr("Salvaging"); + case BehaviorKind::Repair: return QObject::tr("Repairing"); + case BehaviorKind::Rally: return QObject::tr("Rallying"); + case BehaviorKind::Standby: return QObject::tr("Standby"); + case BehaviorKind::Advance: return QObject::tr("Advancing"); + case BehaviorKind::None: break; + } + return QString(); +} diff --git a/src/ui/selection/SelectionNames.h b/src/ui/selection/SelectionNames.h index 12009f4..335639f 100644 --- a/src/ui/selection/SelectionNames.h +++ b/src/ui/selection/SelectionNames.h @@ -2,9 +2,14 @@ #include +#include "BehaviorKind.h" #include "BuildingType.h" // Display name of a building type for the selection panel's header and count rows // (REQ-UI-SELECTION-CARD, REQ-UI-MULTI-SELECTION). The name is derived from the type's // config id, so a new building type needs no entry here. QString getBuildingTypeName(BuildingType type); + +// Name of the behavior currently governing a ship, for the ship card's header slot +// (REQ-UI-SHIP-BEHAVIOR). Empty when no behavior has won yet, which shows no slot. +QString getBehaviorLabel(BehaviorKind kind); diff --git a/src/ui/selection/ShipContent.cpp b/src/ui/selection/ShipContent.cpp index 4319803..535b2d2 100644 --- a/src/ui/selection/ShipContent.cpp +++ b/src/ui/selection/ShipContent.cpp @@ -6,6 +6,7 @@ #include "GameConfig.h" #include "HealthComponent.h" #include "SelectedBehaviorComponent.h" +#include "SelectionNames.h" #include "ShipIdentityComponent.h" #include "ShipStatsCalculator.h" #include "ShipStatsPanel.h" @@ -47,9 +48,13 @@ void ShipContent::refreshRuntime() const ShipStats stats = buildShipStatsFromEntity(admin, m_entity); m_statsPanel->refreshFromLive(stats, health.hp); - m_statsPanel->setBehavior(admin.get(m_entity).winner); m_statsPanel->setDebugDrawEnabled(*getContext().debugDrawEnabled); + // The behavior is the header's right slot rather than a stat row, so it reads as + // what the ship is doing rather than as another number (REQ-UI-SHIP-BEHAVIOR). + setSlot(QColor(), getBehaviorLabel( + admin.get(m_entity).winner)); + const ShipIdentityComponent& identity = admin.get(m_entity); const ShipDef* schematicDef = getContext().config->ships.findShipDef(identity.schematicId); diff --git a/src/ui/selection/ShipyardContent.cpp b/src/ui/selection/ShipyardContent.cpp index 83d0cfa..de399b4 100644 --- a/src/ui/selection/ShipyardContent.cpp +++ b/src/ui/selection/ShipyardContent.cpp @@ -3,14 +3,13 @@ #include #include -#include "Building.h" #include "BuildingTarget.h" #include "EventManager.h" #include "GameConfig.h" #include "LayoutDialogRequestedEvent.h" #include "ProductionRules.h" #include "RecipeSelectionControl.h" -#include "SelectionNames.h" +#include "SectionBox.h" #include "ShipLayoutPreview.h" ShipyardContent::ShipyardContent(const SelectionContext& context, @@ -19,12 +18,17 @@ ShipyardContent::ShipyardContent(const SelectionContext& context, { m_schematicControl = new RecipeSelectionControl(context, getBuildingId(), BuildingType::Shipyard, this); - m_layoutPreview = new ShipLayoutPreview(this); - m_configureButton = new QPushButton(tr("Configure Layout"), this); - getConfigurationLayout()->addWidget(m_schematicControl); - getConfigurationLayout()->addWidget(m_layoutPreview); - getConfigurationLayout()->addWidget(m_configureButton); + m_layoutSection = new SectionBox(tr("Layout"), this); + m_layoutPreview = new ShipLayoutPreview(m_layoutSection); + m_configureButton = new QPushButton(tr("Configure Layout"), m_layoutSection); + m_layoutSection->getContentLayout()->addWidget(m_layoutPreview); + m_layoutSection->getContentLayout()->addWidget(m_configureButton); + + // Ahead of the recipe summary the base adds, so the card reads schematic, layout, + // then what one ship costs. + getConfigurationLayout()->insertWidget(0, m_schematicControl); + getConfigurationLayout()->insertWidget(1, m_layoutSection); const BuildingId id = getBuildingId(); connect(m_configureButton, &QPushButton::clicked, this, [id]() { @@ -33,15 +37,8 @@ ShipyardContent::ShipyardContent(const SelectionContext& context, }); } -void ShipyardContent::refreshConfiguration() +void ShipyardContent::refreshControls(const BuildingTarget& target) { - const BuildingTarget target = resolveBuildingTarget(getContext(), getBuildingId()); - if (!target.isValid()) - { - return; - } - - setBuildingIdentity(target.type, getBuildingTypeName(target.type)); m_schematicControl->setRecipeId(target.recipeId); // The preview and Configure button are always shown for a shipyard and are only @@ -68,12 +65,12 @@ void ShipyardContent::refreshConfiguration() } BufferedBuildingContent::CycleInfo ShipyardContent::getCycleInfo( - const Building& building) const + const BuildingTarget& target) const { CycleInfo info; - const ShipDef* shipDef = building.recipeId.empty() + const ShipDef* shipDef = target.recipeId.empty() ? nullptr - : getContext().config->ships.findShipDef(building.recipeId); + : getContext().config->ships.findShipDef(target.recipeId); if (!shipDef) { return info; @@ -82,13 +79,15 @@ BufferedBuildingContent::CycleInfo ShipyardContent::getCycleInfo( // The schematic's materials plus every placed module's, which is also what sized the // input buffers (REQ-BLD-SHIPYARD). The simulation owns that sum, so the panel asks // it rather than adding the modules up a second time. - info.perCycleInputs = - computeShipyardRequiredMaterials(*getContext().config, building); + info.perCycleInputs = computeShipyardRequiredMaterials( + *getContext().config, target.recipeId, target.shipLayout); + // A shipyard's output is the ship itself, which never lands in an item buffer -- so + // the summary shows one ship rather than an item id. info.durationSeconds = shipDef->schematic.productionTimeSeconds; - if (building.shipLayout.has_value()) + if (target.shipLayout.has_value()) { - for (const PlacedModule& placed : building.shipLayout->placedModules) + for (const PlacedModule& placed : target.shipLayout->placedModules) { const ModuleDef* moduleDef = getContext().config->modules.findModuleDef(placed.moduleId); diff --git a/src/ui/selection/ShipyardContent.h b/src/ui/selection/ShipyardContent.h index 5a07f1a..c80e1bb 100644 --- a/src/ui/selection/ShipyardContent.h +++ b/src/ui/selection/ShipyardContent.h @@ -5,6 +5,7 @@ class QPushButton; class RecipeSelectionControl; +class SectionBox; class ShipLayoutPreview; // The card for a Shipyard (REQ-UI-SELECTION-CONTENT): the schematic selection, the @@ -22,11 +23,12 @@ public: QWidget* parent = nullptr); protected: - void refreshConfiguration() override; - CycleInfo getCycleInfo(const Building& building) const override; + void refreshControls(const BuildingTarget& target) override; + CycleInfo getCycleInfo(const BuildingTarget& target) const override; private: RecipeSelectionControl* m_schematicControl; + SectionBox* m_layoutSection; ShipLayoutPreview* m_layoutPreview; QPushButton* m_configureButton; }; diff --git a/src/ui/selection/StatRow.cpp b/src/ui/selection/StatRow.cpp new file mode 100644 index 0000000..6970c27 --- /dev/null +++ b/src/ui/selection/StatRow.cpp @@ -0,0 +1,54 @@ +#include "StatRow.h" + +#include +#include +#include + +namespace +{ + +// Left inset of an indented row, in device-independent pixels. +const int kIndentPx = 12; + +} // namespace + + +StatRow::StatRow(const QString& label, QWidget* parent) + : QWidget(parent) +{ + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(8); + + m_labelLabel = new QLabel(label, this); + m_valueLabel = new QLabel(this); + m_valueLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + layout->addWidget(m_labelLabel); + layout->addStretch(1); + layout->addWidget(m_valueLabel); +} + +void StatRow::setLabel(const QString& label) +{ + m_labelLabel->setText(label); +} + +void StatRow::setValue(const QString& value) +{ + m_valueLabel->setText(value); +} + +void StatRow::setValueEmphasized(bool emphasized) +{ + QPalette valuePalette = m_valueLabel->palette(); + valuePalette.setColor(QPalette::WindowText, + palette().color(emphasized ? QPalette::Highlight + : QPalette::WindowText)); + m_valueLabel->setPalette(valuePalette); +} + +void StatRow::setIndented(bool indented) +{ + layout()->setContentsMargins(indented ? kIndentPx : 0, 0, 0, 0); +} diff --git a/src/ui/selection/StatRow.h b/src/ui/selection/StatRow.h new file mode 100644 index 0000000..b88b4fe --- /dev/null +++ b/src/ui/selection/StatRow.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include + +class QLabel; + +// One label/value line: the name on the left, the value hard right +// (REQ-UI-SELECTION-CARD). The panel's plainest part, shared by the ship and station +// stats, the debris scrap, the HQ block stock, and the multi-selection's total cost. +// +// Like the other card parts it knows nothing about the simulation: it is given the two +// strings and lays them out. +class StatRow : public QWidget +{ + Q_OBJECT + +public: + explicit StatRow(const QString& label, QWidget* parent = nullptr); + + void setLabel(const QString& label); + void setValue(const QString& value); + // Draws the value in the palette's highlight color rather than its text color, for + // the one value a card is really about. + void setValueEmphasized(bool emphasized); + // Indents the row, so it reads as belonging to the row above it -- the scrap total + // under a debris count (REQ-UI-FIELD-MULTI-SELECTION). + void setIndented(bool indented); + +private: + QLabel* m_labelLabel; + QLabel* m_valueLabel; +}; diff --git a/src/ui/selection/StationContent.cpp b/src/ui/selection/StationContent.cpp index 708ff6c..2d19647 100644 --- a/src/ui/selection/StationContent.cpp +++ b/src/ui/selection/StationContent.cpp @@ -1,13 +1,14 @@ #include "StationContent.h" -#include #include +#include "BarRow.h" #include "EntityAdmin.h" #include "FactionComponent.h" #include "HealthComponent.h" #include "ModuleOwnerComponent.h" #include "Simulation.h" +#include "StatRow.h" #include "WeaponComponent.h" StationContent::StationContent(const SelectionContext& context, @@ -15,9 +16,15 @@ StationContent::StationContent(const SelectionContext& context, : SelectionContent(context, std::nullopt, parent) , m_entity(request.actors.front()) { - m_statsLabel = new QLabel(this); - m_statsLabel->setWordWrap(true); - getRuntimeLayout()->addWidget(m_statsLabel); + m_hpBar = new BarRow(tr("HP"), this); + m_damageRow = new StatRow(tr("Damage"), this); + m_rangeRow = new StatRow(tr("Range"), this); + m_fireRateRow = new StatRow(tr("Fire rate"), this); + + getRuntimeLayout()->addWidget(m_hpBar); + getRuntimeLayout()->addWidget(m_damageRow); + getRuntimeLayout()->addWidget(m_rangeRow); + getRuntimeLayout()->addWidget(m_fireRateRow); EntityAdmin& admin = context.sim->getAdmin(); const bool isEnemy = admin.isValid(m_entity) @@ -36,11 +43,19 @@ void StationContent::refreshRuntime() } const HealthComponent& health = admin.get(m_entity); - // A station's weapons are child module entities pointing back at it, so its combined - // damage and range are summed over those rather than read off the station itself. - float totalDps = 0.0f; - float maxRange = 0.0f; - bool hasWeapon = false; + const double hpFraction = (health.maxHp > 0.0f) + ? static_cast(health.hp) / health.maxHp + : 0.0; + m_hpBar->setValue(hpFraction, tr("%1 / %2") + .arg(static_cast(health.hp + 0.5f)) + .arg(static_cast(health.maxHp + 0.5f))); + + // A station's weapons are child module entities pointing back at it, so its damage, + // range and fire rate are read off those rather than off the station itself. + float totalDamage = 0.0f; + float maxRange = 0.0f; + float maxFireRateHz = 0.0f; + bool hasWeapon = false; const entt::entity station = m_entity; admin.forEach( [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, @@ -48,19 +63,21 @@ void StationContent::refreshRuntime() { if (owner.owner != station) { return; } hasWeapon = true; - totalDps += weapon.damage * weapon.fireRateHz; - if (weapon.range_tiles > maxRange) { maxRange = weapon.range_tiles; } + totalDamage += weapon.damage; + if (weapon.range_tiles > maxRange) { maxRange = weapon.range_tiles; } + if (weapon.fireRateHz > maxFireRateHz) { maxFireRateHz = weapon.fireRateHz; } }); - QString text = tr("HP: %1 / %2") - .arg(static_cast(health.hp + 0.5f)) - .arg(static_cast(health.maxHp + 0.5f)); + m_damageRow->setVisible(hasWeapon); + m_rangeRow->setVisible(hasWeapon); + m_fireRateRow->setVisible(hasWeapon); if (hasWeapon) { - text += tr("\nDPS: %1") - .arg(QString::number(static_cast(totalDps), 'f', 1)); - text += tr("\nRange: %1 tiles") - .arg(QString::number(static_cast(maxRange), 'f', 1)); + m_damageRow->setValue( + QString::number(static_cast(totalDamage), 'f', 1)); + m_rangeRow->setValue(tr("%1 tiles") + .arg(QString::number(static_cast(maxRange), 'f', 1))); + m_fireRateRow->setValue(tr("%1 /s") + .arg(QString::number(static_cast(maxFireRateHz), 'f', 1))); } - m_statsLabel->setText(text); } diff --git a/src/ui/selection/StationContent.h b/src/ui/selection/StationContent.h index c7af74a..e4ffc53 100644 --- a/src/ui/selection/StationContent.h +++ b/src/ui/selection/StationContent.h @@ -5,7 +5,8 @@ #include "SelectionContent.h" #include "SelectionContentFactory.h" -class QLabel; +class BarRow; +class StatRow; // The card for one selected defence station, player or enemy // (REQ-UI-STATION-STATS-PANEL): its HP plus the combined damage, range and fire rate of @@ -23,5 +24,8 @@ protected: private: entt::entity m_entity; - QLabel* m_statsLabel; + BarRow* m_hpBar; + StatRow* m_damageRow; + StatRow* m_rangeRow; + StatRow* m_fireRateRow; }; diff --git a/src/ui/selection/StatusPill.cpp b/src/ui/selection/StatusPill.cpp new file mode 100644 index 0000000..576bed7 --- /dev/null +++ b/src/ui/selection/StatusPill.cpp @@ -0,0 +1,68 @@ +#include "StatusPill.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +// Diameter of the status dot, in device-independent pixels. +const int kDotSizePx = 8; + +QPixmap renderDot(const QColor& fill, const QColor& outline) +{ + const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0; + QPixmap pixmap(static_cast(kDotSizePx * dpr), + static_cast(kDotSizePx * dpr)); + pixmap.setDevicePixelRatio(dpr); + pixmap.fill(Qt::transparent); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(outline.isValid() ? QPen(outline) : QPen(Qt::NoPen)); + painter.setBrush(fill); + // Inset by half the pen width so the outline stays inside the pixmap. + painter.drawEllipse(QRectF(0.5, 0.5, kDotSizePx - 1.0, kDotSizePx - 1.0)); + return pixmap; +} + +} // namespace + + +StatusPill::StatusPill(QWidget* parent) + : QWidget(parent) +{ + QHBoxLayout* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(4); + + m_dotLabel = new QLabel(this); + m_captionLabel = new QLabel(this); + + layout->addWidget(m_dotLabel); + layout->addWidget(m_captionLabel); + + hide(); +} + +void StatusPill::setStatus(const QColor& dotColor, const QColor& outlineColor, + const QString& caption) +{ + if (dotColor.isValid()) + { + m_dotLabel->setPixmap(renderDot(dotColor, outlineColor)); + m_dotLabel->show(); + } + else + { + m_dotLabel->hide(); + } + + m_captionLabel->setText(caption); + m_captionLabel->setVisible(!caption.isEmpty()); + setVisible(dotColor.isValid() || !caption.isEmpty()); +} diff --git a/src/ui/selection/StatusPill.h b/src/ui/selection/StatusPill.h new file mode 100644 index 0000000..1dfc947 --- /dev/null +++ b/src/ui/selection/StatusPill.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +class QLabel; + +// The card header's right slot (REQ-UI-SELECTION-CARD): a colored dot with a short +// caption beside it. It carries a building's production status +// (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an +// aggregated selection's object count (REQ-UI-SELECTION-AGGREGATE) -- never more than +// one of them, which is why they share one part. +class StatusPill : public QWidget +{ + Q_OBJECT + +public: + explicit StatusPill(QWidget* parent = nullptr); + + // An invalid dot color leaves the dot off, for the slots that are a plain caption. + // An empty caption hides the pill entirely. + void setStatus(const QColor& dotColor, const QColor& outlineColor, + const QString& caption); + +private: + QLabel* m_dotLabel; + QLabel* m_captionLabel; +}; diff --git a/src/ui/selection/StorageContent.cpp b/src/ui/selection/StorageContent.cpp index e0fe9f5..d4a9565 100644 --- a/src/ui/selection/StorageContent.cpp +++ b/src/ui/selection/StorageContent.cpp @@ -1,8 +1,6 @@ #include "StorageContent.h" -#include "Building.h" #include "BuildingTarget.h" -#include "SelectionNames.h" StorageContent::StorageContent(const SelectionContext& context, const SelectionRequest& request, QWidget* parent) @@ -10,20 +8,10 @@ StorageContent::StorageContent(const SelectionContext& context, { } -void StorageContent::refreshConfiguration() -{ - const BuildingTarget target = resolveBuildingTarget(getContext(), getBuildingId()); - if (!target.isValid()) - { - return; - } - setBuildingIdentity(target.type, getBuildingTypeName(target.type)); -} - BufferedBuildingContent::CycleInfo StorageContent::getCycleInfo( - const Building& /*building*/) const + const BuildingTarget& /*target*/) const { // No recipe, no cycle: the card shows the output buffer alone, with no per-cycle - // denominators to show it against (REQ-BLD-SALVAGE-BAY). + // denominators and no production progress (REQ-BLD-SALVAGE-BAY). return CycleInfo(); } diff --git a/src/ui/selection/StorageContent.h b/src/ui/selection/StorageContent.h index d4ac026..f16a9fe 100644 --- a/src/ui/selection/StorageContent.h +++ b/src/ui/selection/StorageContent.h @@ -17,6 +17,5 @@ public: QWidget* parent = nullptr); protected: - void refreshConfiguration() override; - CycleInfo getCycleInfo(const Building& building) const override; + CycleInfo getCycleInfo(const BuildingTarget& target) const override; };