split the selection panel into one card per kind of selection

This commit is contained in:
2026-08-07 18:38:55 +02:00
parent cc0ef856e3
commit 2289277e12
60 changed files with 3014 additions and 1559 deletions

View File

@@ -78,6 +78,7 @@ unset(SRCS)
set(HDRS)
set(SRCS)
set(UI_INCLUDE_PATH)
add_subdirectory(ui)
@@ -106,6 +107,7 @@ set_target_properties(${TARGET_UI_NAME} PROPERTIES
)
target_include_directories(${TARGET_UI_NAME} PUBLIC
"${TARGET_UI_INCLUDE_DIRS}"
"${UI_INCLUDE_PATH}"
"${TARGET_LIB_INCLUDE_DIRS}"
"${LIB_INCLUDE_PATH}"
)

View File

@@ -2,9 +2,7 @@
#include <string>
#include <QByteArray>
#include <QColor>
#include <QFile>
#include <QFont>
#include <QFontMetrics>
#include <QGuiApplication>
@@ -15,12 +13,11 @@
#include <QPixmap>
#include <QPushButton>
#include <QRect>
#include <QRegularExpression>
#include <QSignalMapper>
#include <QSize>
#include <QString>
#include <QSvgRenderer>
#include "BuildingIconCache.h"
#include "BuildingType.h"
#include "BuildingTypeSelectedEvent.h"
#include "DeconstructModeToggleRequestedEvent.h"
@@ -54,45 +51,15 @@ namespace
// Gap between the chip icon and the cost line on a button face.
const int kFaceGapPx = 2;
// Rasterizes a chip SVG straight at its on-screen size times the device pixel
// ratio, so it stays crisp without a downscale step.
QPixmap renderChip(const QByteArray& svg)
{
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QSvgRenderer renderer(svg);
QPixmap pixmap(static_cast<int>(kIconSize.width() * dpr),
static_cast<int>(kIconSize.height() * dpr));
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
renderer.render(&painter);
return pixmap;
}
// Normal and grey-background chip pixmaps for a "<id>.svg" file. Empty pixmaps if
// the file cannot be read; the caller then falls back to a name caption
// Normal and grey-background chip pixmaps for one icon name. Empty pixmaps if the
// file cannot be read; the caller then falls back to a name caption
// (REQ-UI-BUILD-ICON).
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
ChipPixmaps loadChipPixmaps(const QString& path)
ChipPixmaps loadChipPixmaps(BuildingIconCache& icons, const std::string& iconName)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) { return {}; }
const QByteArray svg = file.readAll();
ChipPixmaps result;
result.normal = renderChip(svg);
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
// rounded background rect; the white glyph uses fill="none" and is left alone.
QString greyed = QString::fromUtf8(svg);
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
const QRegularExpressionMatch match = fillPattern.match(greyed);
if (match.hasMatch())
{
greyed.replace(match.capturedStart(), match.capturedLength(),
QStringLiteral("fill=\"#5f636e\""));
}
result.grey = renderChip(greyed.toUtf8());
result.normal = icons.getChip(iconName, kIconSize.width());
result.grey = icons.getGreyChip(iconName, kIconSize.width());
return result;
}
@@ -181,12 +148,12 @@ namespace
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
const std::string& iconDir,
BuildingIconCache* buildingIcons,
ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_iconDir(iconDir)
, m_buildingIcons(buildingIcons)
, m_itemIcons(itemIcons)
{
// The bar floats over the rendered world rather than sitting in a panel, so it
@@ -231,13 +198,12 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
const QString name = (def.type == BuildingType::TunnelEntry)
? tr("Tunnel")
: QString::fromStdString(toDisplayName(def.id));
// Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's
// "tunnel_entry.svg" serves the shared Tunnel button.
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
+ QString::fromStdString(def.id) + ".svg";
const ButtonFace face = buildButtonFace(
loadChipPixmaps(iconPath), InputMapper::getBuildHotkeyLabel(def.type), name,
loadChipPixmaps(*m_buildingIcons, def.id),
InputMapper::getBuildHotkeyLabel(def.type), name,
QString::number(def.cost), blockIcon, font(), palette());
QPushButton* btn = new QPushButton(this);
@@ -268,7 +234,7 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
// Having no cost, it shows its name where the building buttons show theirs
// (REQ-UI-DECONSTRUCT-BUTTON), and its Q toggle as the badge (REQ-UI-HOTKEYS).
const ButtonFace deconstructFace = buildButtonFace(
loadChipPixmaps(QString::fromStdString(m_iconDir) + "/deconstruct.svg"),
loadChipPixmaps(*m_buildingIcons, "deconstruct"),
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
font(), palette());

View File

@@ -20,6 +20,7 @@
class QPushButton;
class Simulation;
class BuildingIconCache;
class ItemIconCache;
// The build menu: one horizontal row of build buttons floating over the game world
@@ -36,13 +37,11 @@ class BuildButtonBar : public QWidget,
Q_OBJECT
public:
// iconDir is the directory holding the per-building "<id>.svg" chip icons
// (REQ-UI-BUILD-ICON), read from disk at runtime like the config files.
// itemIcons is the window-wide per-item icon cache and supplies the
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Not
// owned; must outlive this widget.
// buildingIcons supplies each button's chip icon (REQ-UI-BUILD-ICON) and itemIcons
// the building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Both are
// window-wide caches; neither is owned, and both must outlive this widget.
BuildButtonBar(Simulation* sim, const GameConfig* config,
const std::string& iconDir, ItemIconCache* itemIcons,
BuildingIconCache* buildingIcons, ItemIconCache* itemIcons,
QWidget* parent = nullptr);
~BuildButtonBar() override;
@@ -85,8 +84,8 @@ private slots:
private:
Simulation* m_sim;
const GameConfig* m_config;
std::string m_iconDir;
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
BuildingIconCache* m_buildingIcons; // Not owned; lives in MainWindow.
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
std::vector<BuildingType> m_types;
std::vector<QPushButton*> m_buttons;
std::map<BuildingType, int> m_costs;

View File

@@ -0,0 +1,120 @@
#include "BuildingIconCache.h"
#include <QFile>
#include <QGuiApplication>
#include <QPainter>
#include <QRegularExpression>
#include <QSvgRenderer>
namespace
{
// Chip background color for a build button the player cannot afford
// (REQ-UI-BUILD-DISABLED). Part of the icon rather than of any widget's palette, so it
// lives with the recoloring step.
const char* const kGreyFill = "fill=\"#5f636e\"";
} // namespace
BuildingIconCache::BuildingIconCache(const QString& iconDir)
: m_iconDir(iconDir)
{
}
const QByteArray& BuildingIconCache::getSvg(const std::string& iconName)
{
const std::map<std::string, QByteArray>::const_iterator cached =
m_svgByName.find(iconName);
if (cached != m_svgByName.end())
{
return cached->second;
}
// An absent or unreadable file caches an empty byte array so it is not retried;
// a missing icon is not an error (REQ-UI-BUILD-ICON).
QByteArray svg;
QFile file(m_iconDir + "/" + QString::fromStdString(iconName) + ".svg");
if (file.open(QIODevice::ReadOnly))
{
svg = file.readAll();
}
return m_svgByName.emplace(iconName, std::move(svg)).first->second;
}
const QByteArray& BuildingIconCache::getGreySvg(const std::string& iconName)
{
const std::map<std::string, QByteArray>::const_iterator cached =
m_greySvgByName.find(iconName);
if (cached != m_greySvgByName.end())
{
return cached->second;
}
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
// rounded background rect; the white glyph uses fill="none" and is left alone.
const QByteArray& svg = getSvg(iconName);
QByteArray greyed = svg;
if (!svg.isEmpty())
{
QString text = QString::fromUtf8(svg);
static const QRegularExpression fillPattern(
QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
const QRegularExpressionMatch match = fillPattern.match(text);
if (match.hasMatch())
{
text.replace(match.capturedStart(), match.capturedLength(),
QLatin1String(kGreyFill));
}
greyed = text.toUtf8();
}
return m_greySvgByName.emplace(iconName, std::move(greyed)).first->second;
}
bool BuildingIconCache::hasIcon(const std::string& iconName)
{
return !getSvg(iconName).isEmpty();
}
QPixmap BuildingIconCache::getChip(const std::string& iconName, int sizePx)
{
return getPixmap(iconName, getSvg(iconName), sizePx);
}
QPixmap BuildingIconCache::getGreyChip(const std::string& iconName, int sizePx)
{
return getPixmap("grey:" + iconName, getGreySvg(iconName), sizePx);
}
QPixmap BuildingIconCache::getPixmap(const std::string& cacheKey,
const QByteArray& svg, int sizePx)
{
if (sizePx <= 0)
{
return QPixmap();
}
const std::pair<std::string, int> key(cacheKey, sizePx);
const std::map<std::pair<std::string, int>, QPixmap>::const_iterator cached =
m_pixmapCache.find(key);
if (cached != m_pixmapCache.end())
{
return cached->second;
}
QPixmap pixmap;
if (!svg.isEmpty())
{
// Rasterized straight at its on-screen size times the device pixel ratio, so it
// stays crisp without a downscale step.
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QSvgRenderer renderer(svg);
pixmap = QPixmap(static_cast<int>(sizePx * dpr), static_cast<int>(sizePx * dpr));
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
renderer.render(&painter);
}
return m_pixmapCache.emplace(key, std::move(pixmap)).first->second;
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <map>
#include <string>
#include <utility>
#include <QByteArray>
#include <QPixmap>
#include <QString>
// Rasterizes and caches the per-building chip SVGs (REQ-UI-BUILD-ICON). A chip is a
// rounded colored background bearing a white line glyph, loaded from a directory with
// one file per building named after the building's id (e.g. "belt.svg"). Shared by the
// build button bar and the selection panel's card header (REQ-UI-SELECTION-CARD) so the
// rasterization is not duplicated.
//
// A missing icon file is not an error: hasIcon() returns false for it and the caller
// falls back (a name caption on a build button, a bare title in the panel header).
class BuildingIconCache
{
public:
// iconDir is the directory holding the "<id>.svg" chip files (typically
// "<configDir>/../icons/buildings").
explicit BuildingIconCache(const QString& iconDir);
// True if a chip SVG file exists for the given icon name. Loads the file's bytes on
// first query and remembers the result (including absence) so repeated calls are
// cheap. The name is a building id for the building buttons, but need not be one --
// the Deconstruct button's "deconstruct" chip goes through the same path.
bool hasIcon(const std::string& iconName);
// The chip rasterized to a transparent sizePx*sizePx pixmap at the device pixel
// ratio, cached per (name, size). Returns a null pixmap when the file is missing.
QPixmap getChip(const std::string& iconName, int sizePx);
// As getChip(), but with the chip background recolored grey and the glyph left
// alone, for a build button the player cannot currently afford
// (REQ-UI-BUILD-DISABLED).
QPixmap getGreyChip(const std::string& iconName, int sizePx);
private:
// Raw SVG bytes for an icon name, loading and caching them on first access. An
// absent file caches an empty QByteArray so it is not retried.
const QByteArray& getSvg(const std::string& iconName);
// The same SVG with its background fill replaced by grey, cached alongside.
const QByteArray& getGreySvg(const std::string& iconName);
// Shared rasterize-and-cache step. cacheKey distinguishes the normal and grey
// variants of one icon name within the single pixmap cache.
QPixmap getPixmap(const std::string& cacheKey, const QByteArray& svg, int sizePx);
QString m_iconDir;
std::map<std::string, QByteArray> m_svgByName;
std::map<std::string, QByteArray> m_greySvgByName;
std::map<std::pair<std::string, int>, QPixmap> m_pixmapCache;
};

View File

@@ -1,3 +1,5 @@
add_subdirectory(selection)
SET(HDRS
${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
@@ -12,7 +14,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
@@ -22,10 +23,16 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.h
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h
PARENT_SCOPE
)
set(UI_INCLUDE_PATH
${UI_INCLUDE_PATH}
PARENT_SCOPE
)
SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
@@ -38,7 +45,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
@@ -48,6 +54,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.cpp
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp
PARENT_SCOPE
)

View File

@@ -1,403 +0,0 @@
#include "FieldSelectionPanel.h"
#include <algorithm>
#include <map>
#include <string>
#include <QFont>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
#include "DebrisSystem.h"
#include "DisplayName.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "ThreatCostCalculator.h"
#include "WeaponComponent.h"
FieldSelectionPanel::FieldSelectionPanel(Simulation* sim,
const GameConfig* config,
QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
{
// Zero margins and the same spacing as the enclosing SelectionPanel layout, so
// nesting the field widgets in this panel leaves their geometry unchanged.
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(4);
m_layout->setAlignment(Qt::AlignTop);
m_entityTitleLabel = new QLabel(this);
QFont titleFont = m_entityTitleLabel->font();
titleFont.setBold(true);
m_entityTitleLabel->setFont(titleFont);
m_layout->addWidget(m_entityTitleLabel);
m_entityTitleLabel->hide();
m_entityStatsPanel = new ShipStatsPanel(config, this);
m_layout->addWidget(m_entityStatsPanel);
m_entityStatsPanel->hide();
m_stationStatsLabel = new QLabel(this);
m_stationStatsLabel->setWordWrap(true);
m_layout->addWidget(m_stationStatsLabel);
m_stationStatsLabel->hide();
m_entitySummaryLabel = new QLabel(this);
m_entitySummaryLabel->setWordWrap(true);
m_layout->addWidget(m_entitySummaryLabel);
m_entitySummaryLabel->hide();
m_scrapLabel = new QLabel(this);
m_layout->addWidget(m_scrapLabel);
m_scrapLabel->hide();
hide();
registerForEvents();
}
FieldSelectionPanel::~FieldSelectionPanel()
{
unregisterForEvents();
}
void FieldSelectionPanel::setSelectedEntities(const std::vector<entt::entity>& entities)
{
m_selectedEntities = entities;
rebuild();
}
void FieldSelectionPanel::setSelectedDebris(const std::vector<entt::entity>& debris)
{
m_selectedDebris = debris;
rebuild();
}
void FieldSelectionPanel::clearSelection()
{
m_selectedEntities.clear();
m_selectedDebris.clear();
rebuild();
}
bool FieldSelectionPanel::hasSelection() const
{
return !m_selectedEntities.empty() || !m_selectedDebris.empty();
}
void FieldSelectionPanel::hideAllWidgets()
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
}
void FieldSelectionPanel::rebuild()
{
if (!hasSelection())
{
// Nothing in the field category: take no space, leaving the panel to whatever
// the building category shows (REQ-UI-SELECTION-CATEGORIES).
hideAllWidgets();
hide();
return;
}
show();
EntityAdmin& admin = m_sim->getAdmin();
// A full single-object stats panel is shown only for a lone field object: one actor
// with no debris, or one piece of debris with no actors. As soon as the selection holds
// more than one object (multiple actors, multiple debris, or actors plus debris), the
// panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
const entt::entity entity = m_selectedEntities.front();
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
{
buildEntityShip(entity);
}
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
else
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
}
return;
}
if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
// Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like
// the ship/station stats panels (REQ-UI-DEBRIS-PANEL).
m_entitySummaryLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
buildDebrisSingle();
return;
}
// More than one field object: a compact count summary. buildEntitySummary() appends the
// "Debris x N" and "Scrap x N" lines when debris is part of the selection.
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_scrapLabel->hide();
buildEntitySummary();
}
void FieldSelectionPanel::refreshDisplay()
{
if (!hasSelection()) { return; }
// Keep the live values current: the single-actor stats panel, the single-debris stats
// panel (whose Scrap row shrinks as it is collected), or the count summary (whose Scrap
// line shrinks likewise) — matching the layout chosen by rebuild()
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
refreshEntityStats();
}
else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
buildDebrisSingle();
}
else
{
buildEntitySummary();
}
}
void FieldSelectionPanel::buildDebrisSingle()
{
// "Debris" heading + a single "Scrap" stat row for the piece's remaining amount,
// mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL).
m_entityTitleLabel->setText(tr("Debris"));
m_entityTitleLabel->show();
m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal()));
m_scrapLabel->show();
}
void FieldSelectionPanel::buildEntitySummary()
{
EntityAdmin& admin = m_sim->getAdmin();
// Group actors by faction + kind + ship schematic, preserving first-seen order
// (REQ-UI-FIELD-MULTI-SELECTION).
std::vector<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity entity : m_selectedEntities)
{
if (!admin.isValid(entity)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
const QString name = QString::fromStdString(toDisplayName(id));
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
+ QString::fromStdString(id);
label = isEnemy ? tr("Enemy %1").arg(name) : name;
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
}
else
{
continue;
}
if (counts.find(key) == counts.end())
{
keys.push_back(key);
labels[key] = label;
}
counts[key] += 1;
}
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
// multi-selection). No total-count header, consistent with the building panel. When
// debris is part of the selection, a "Debris x <count>" line followed by a
// "Scrap x <total>" line are appended into the same label so the line spacing is
// uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_selectedDebris.empty())
{
lines << tr("Debris x %1").arg(static_cast<int>(m_selectedDebris.size()));
lines << scrapTotalText();
}
m_entitySummaryLabel->setText(lines.join('\n'));
m_entitySummaryLabel->show();
}
void FieldSelectionPanel::buildEntityShip(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
const HealthComponent& health = admin.get<HealthComponent>(entity);
m_entityTitleLabel->setText(tr("Ship: %1")
.arg(QString::fromStdString(identity.schematicId)));
m_entityTitleLabel->show();
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
const ShipDef* schematicDef =
m_config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
const double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, schematicDef->id,
schematicDef->defaultModules);
m_entityStatsPanel->setThreatCost(threat);
}
m_entityStatsPanel->show();
m_stationStatsLabel->hide();
}
void FieldSelectionPanel::buildEntityStation(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const HealthComponent& health = admin.get<HealthComponent>(entity);
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
m_entityTitleLabel->setText(isEnemy
? tr("Enemy Defence Station")
: tr("Player Defence Station"));
m_entityTitleLabel->show();
float totalDps = 0.0f;
float maxRange = 0.0f;
bool hasWeapons = false;
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
{
if (owner.owner != entity) { return; }
hasWeapons = true;
totalDps += w.damage * w.fireRateHz;
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
});
QString statsText = tr("HP: %1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f));
if (hasWeapons)
{
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
}
m_stationStatsLabel->setText(statsText);
m_stationStatsLabel->show();
m_entityStatsPanel->hide();
}
void FieldSelectionPanel::refreshEntityStats()
{
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
if (m_selectedEntities.size() != 1) { return; }
EntityAdmin& admin = m_sim->getAdmin();
const entt::entity entity = m_selectedEntities.front();
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
const HealthComponent& health = admin.get<HealthComponent>(entity);
if (health.hp <= 0.0f) { return; }
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
}
int FieldSelectionPanel::selectedDebrisScrapTotal() const
{
// Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL).
int total = 0;
for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end())
{
total += info.amount;
}
}
return total;
}
QString FieldSelectionPanel::scrapTotalText() const
{
return tr("Scrap x %1").arg(selectedDebrisScrapTotal());
}
void FieldSelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{
refreshDisplay();
}
void FieldSelectionPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{
// Player commands are applied by a queued drain, not synchronously. When the game is
// paused no tick advances, so TickAdvancedEvent never fires; refresh here too.
refreshDisplay();
}
void FieldSelectionPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
{
m_debugDraw = event->active;
m_entityStatsPanel->setDebugDrawEnabled(event->active);
}

View File

@@ -1,91 +0,0 @@
#pragma once
#include <vector>
#include <QString>
#include <QWidget>
#include "entt/entity/entity.hpp"
#include "DebugDrawToggledEvent.h"
#include "EventHandler.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h"
struct GameConfig;
class Simulation;
class ShipStatsPanel;
class QLabel;
class QVBoxLayout;
// Renders the "field" selection category — ships, defence stations and debris — as either
// a single-object stats panel (ship, station, or debris) or a compact multi-object count
// summary (REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
//
// The panel owns its own selection state and its own widgets, and nothing else. Which of
// the two selection categories owns the selection panel is arbitrated by the parent
// SelectionPanel: it feeds this panel through setSelectedEntities() /
// setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides
// itself whenever its selection is empty, so an inactive field category takes no space.
class FieldSelectionPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
DebugDrawToggledEvent>
{
Q_OBJECT
public:
FieldSelectionPanel(Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr);
~FieldSelectionPanel() override;
// Replaces the selected actors (ships and defence stations); debris is left alone,
// the two coexist within the field category (REQ-UI-SELECTION-CATEGORIES).
void setSelectedEntities(const std::vector<entt::entity>& entities);
// Replaces the selected debris; the selected actors are left alone.
void setSelectedDebris(const std::vector<entt::entity>& debris);
// Drops the whole field selection — used when the building category takes over.
void clearSelection();
// True while the field category has anything selected, i.e. while this panel owns
// the selection panel's content.
bool hasSelection() const;
private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
// Picks the layout for the current selection and shows/hides this panel accordingly.
void rebuild();
// Keeps the live values of the layout chosen by rebuild() current.
void refreshDisplay();
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void buildDebrisSingle();
void refreshEntityStats();
void hideAllWidgets();
// Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL).
int selectedDebrisScrapTotal() const;
// "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION).
QString scrapTotalText() const;
Simulation* m_sim;
const GameConfig* m_config;
bool m_debugDraw = false;
// The selected ships/defence stations. Shares the "field" selection category with
// debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedDebris;
QVBoxLayout* m_layout;
QLabel* m_entityTitleLabel;
ShipStatsPanel* m_entityStatsPanel;
QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
// Shows the debris "Scrap" stat row (single selection) — the scrap total for the
// multi-object summary lives in m_entitySummaryLabel instead.
QLabel* m_scrapLabel;
};

View File

@@ -31,6 +31,7 @@
#include "SelectionPanel.h"
#include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h"
#include "BuildingIconCache.h"
#include "ItemIconCache.h"
#include "ModalPauseScope.h"
#include "Simulation.h"
@@ -48,31 +49,28 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
setWindowTitle(tr("Dota Factory"));
resize(1280, 768);
// Item icons live alongside the config (a sibling of the config dir), read from
// disk at runtime like the building icons and visuals.toml (REQ-UI-ITEM-ICON).
const std::string itemsIconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/items").toStdString();
// Item and building icons live alongside the config (siblings of the config dir),
// read from disk at runtime the same way visuals.toml is (REQ-UI-ITEM-ICON,
// REQ-UI-BUILD-ICON).
const QString configDirPath = QString::fromStdString(m_configDir);
m_itemIcons = std::make_unique<ItemIconCache>(
QString::fromStdString(itemsIconDir));
QDir::cleanPath(configDirPath + "/../icons/items"));
m_buildingIcons = std::make_unique<BuildingIconCache>(
QDir::cleanPath(configDirPath + "/../icons/buildings"));
m_headerBar = new HeaderBar(sim, &sim->getConfig(), m_itemIcons.get(), this);
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_itemIcons.get(), m_replay.get(), this);
// Building icons live alongside the config (a sibling of the config dir), read
// from disk at runtime the same way visuals.toml is.
const std::string iconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
// Floats over the game world at its bottom center, sized to its buttons
// (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
// building it after the world view puts it above the world and its vignettes,
// and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM).
// Its geometry comes from layoutPanels().
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir,
m_itemIcons.get(), this);
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(),
m_buildingIcons.get(), m_itemIcons.get(),
this);
// The blueprints have no widget of their own: they are saved with Ctrl+C and picked
// from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window
@@ -85,7 +83,9 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
// the build button bar it is a sibling of the world view built after it, which is
// what puts it above the world and its vignettes and below the dim overlay. It
// brings its own chrome; its geometry comes from layoutPanels().
m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), this);
m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), &m_visuals,
m_itemIcons.get(), m_buildingIcons.get(),
this);
// Created last so it stacks above the other children; covers the whole window and
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).

View File

@@ -31,6 +31,7 @@ class HeaderBar;
class SelectionPanel;
class BuildButtonBar;
class BlueprintLibrary;
class BuildingIconCache;
class ItemIconCache;
class QCloseEvent;
class QResizeEvent;
@@ -92,6 +93,9 @@ private:
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
// build bar, world view, and recipe dialog all rasterize the same SVGs.
std::unique_ptr<ItemIconCache> m_itemIcons;
// Likewise one per-building chip cache (REQ-UI-BUILD-ICON), shared by the build bar
// and the selection panel's card headers (REQ-UI-SELECTION-CARD).
std::unique_ptr<BuildingIconCache> m_buildingIcons;
GameWorldView* m_gameWorldView;
HeaderBar* m_headerBar;
SelectionPanel* m_selectionPanel;

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,34 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include <QPoint>
#include <QRect>
#include <QWidget>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingId.h"
#include "DebrisSelectionChangedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipesConfig.h"
#include "DebrisSelectionChangedEvent.h"
#include "SelectionChangedEvent.h"
#include "ShipLayout.h"
#include "ShipsConfig.h"
#include "Tick.h"
#include "TickAdvancedEvent.h"
#include "selection/SelectionContentFactory.h"
#include "selection/SelectionContext.h"
struct GameConfig;
struct VisualsConfig;
class BuildingIconCache;
class ItemIconCache;
class SelectionContent;
class Simulation;
class FieldSelectionPanel;
class ShipLayoutPreview;
class QLabel;
class QListWidget;
class QPushButton;
class QScrollArea;
class QVBoxLayout;
// Shows the current selection. The building category (buildings and construction sites)
// is rendered by this panel itself; the field category (ships, defence stations, debris)
// is rendered by the embedded FieldSelectionPanel.
//
// The two categories are mutually exclusive (REQ-UI-SELECTION-CATEGORIES) and this panel
// is the sole arbiter of which one owns the content: it listens to all three selection
// events, forwards the field ones to the child panel, and drops the losing category's
// content. Neither panel touches the other's widgets.
// Shows the current selection. The panel itself renders nothing: it arbitrates between
// the two selection categories (REQ-UI-SELECTION-CATEGORIES), picks the card that fits
// what is selected (REQ-UI-SELECTION-CONTENT), and hosts exactly one of them at a time.
// What each card looks like lives in src/ui/selection/.
//
// The panel floats over the game world view rather than occupying a column of its own
// (REQ-UI-SELECTION-PANEL): it sizes itself to its content, anchors to the right edge of
// (REQ-UI-SELECTION-PANEL): it sizes itself to its card, anchors to the right edge of
// the band its owner hands it, and hides itself entirely while nothing is selected
// (REQ-UI-EMPTY-SELECTION).
class SelectionPanel : public QWidget,
@@ -50,13 +36,17 @@ class SelectionPanel : public QWidget,
PlayerCommandsAppliedEvent,
EntitySelectionChangedEvent,
SelectionChangedEvent,
DebrisSelectionChangedEvent>
DebrisSelectionChangedEvent,
DebugDrawToggledEvent>
{
Q_OBJECT
public:
// visuals, itemIcons and buildingIcons are window-wide rendering resources the cards
// draw from; none is owned and all must outlive this widget.
SelectionPanel(Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr);
const VisualsConfig* visuals, ItemIconCache* itemIcons,
BuildingIconCache* buildingIcons, QWidget* parent = nullptr);
~SelectionPanel() override;
// Confines the panel to the given band of the game world view: it right-aligns
@@ -66,86 +56,41 @@ public:
void anchorTo(const QRect& bandRect);
private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
private slots:
void onSelectRecipeClicked();
void onClearBelt();
void onSplitterFilterChanged();
private:
// Why the selection display is being refreshed. A periodic tick only needs a
// lightweight content update (e.g. a construction site's progress label),
// whereas an applied player command may have changed the configuration and
// needs a full structural rebuild.
enum class RefreshReason
{
PeriodicTick,
CommandApplied
};
void onSelectionChanged(const std::vector<BuildingId>& ids);
// Gives the panel to the field category once it has anything selected.
void yieldToFieldSelection();
// Shows the panel while either category has a selection and hides it otherwise
// (REQ-UI-EMPTY-SELECTION), re-fitting it to its current content while it is shown.
// Every path that changes the content ends here, because the content is what sizes
// the panel.
// Re-reads the live values of the card on screen. When the selection now calls for a
// different card -- a construction site finishing is the case that matters -- it
// rebuilds instead, because a card never changes its own shape.
void refreshContent();
// Replaces the card with the one the current selection calls for.
void rebuildContent();
// Shows the panel while a card exists and hides it otherwise
// (REQ-UI-EMPTY-SELECTION), re-fitting it to the card while it is shown.
void updateVisibility();
// Re-fits the panel to its content within the anchored band.
// Re-fits the panel to its card within the anchored band.
void refit();
void refreshSelectionDisplay(RefreshReason reason);
void rebuild();
void hideAllWidgets();
void buildEmpty();
void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids);
void refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type,
const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout);
void buildSplitterFilters(const std::optional<BeltSystem::SplitterInfo>& info);
const RecipeDef* findRecipe(const Building* b) const;
const ShipDef* findShipDef(const std::string& id) const;
std::vector<std::string> getAllItemIds() const;
Simulation* m_sim;
const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds;
SelectionContext m_context;
// Read through m_context by the cards that need it, so a toggle reaches the card on
// screen without it having to subscribe to the event itself.
bool m_debugDrawEnabled = false;
SelectionRequest m_request;
ContentKey m_contentKey;
SelectionContent* m_content = nullptr;
// The band the panel confines itself to, in the coordinates of its parent; null
// until the owner has anchored it for the first time.
QRect m_bandRect;
// Scrolls the content once it outgrows the band (REQ-UI-SELECTION-PANEL). All the
// content widgets below are children of m_content, not of the panel itself.
// Scrolls the card once it outgrows the band (REQ-UI-SELECTION-PANEL). The card is a
// child of m_body, not of the panel itself.
QScrollArea* m_scrollArea;
QWidget* m_content;
QVBoxLayout* m_layout;
QLabel* m_titleLabel;
QPushButton* m_recipeSelectButton;
QPushButton* m_clearBeltBtn;
QLabel* m_filterALabel;
QListWidget* m_filterAList;
QLabel* m_filterBLabel;
QListWidget* m_filterBList;
QLabel* m_buffersLabel;
ShipLayoutPreview* m_layoutPreview;
QPushButton* m_configureLayoutBtn;
std::optional<BuildingId> m_singleBuildingId;
bool m_singleIsSite = false; // selected single entity is a construction site
QPoint m_splitterTile;
std::string m_currentRecipeId;
// Renders the field selection (actors + debris) below the building content
// (REQ-UI-FIELD-MULTI-SELECTION). Hides itself while nothing field-side is selected.
FieldSelectionPanel* m_fieldSelectionPanel;
QWidget* m_body;
QVBoxLayout* m_bodyLayout;
};

View File

@@ -0,0 +1,55 @@
#include "AutoProductionContent.h"
#include "Building.h"
#include "BuildingTarget.h"
#include "GameConfig.h"
#include "SelectionNames.h"
AutoProductionContent::AutoProductionContent(const SelectionContext& context,
const SelectionRequest& request,
QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
}
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
{
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.
info.runsProduction = true;
if (!building.production.has_value())
{
return info;
}
const RecipeDef* recipe = getContext().config->recipes.findRecipeDef(
building.production->recipeId, building.type);
if (!recipe)
{
return info;
}
for (const RecipeIngredient& ingredient : recipe->inputs)
{
info.perCycleInputs[ingredient.item] = ingredient.amount;
}
for (const RecipeOutput& output : recipe->outputs)
{
info.perCycleOutputs[output.item] = output.amount;
}
info.durationSeconds = recipe->durationSeconds;
return info;
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
// The card for a Smelter or a Reprocessing Plant (REQ-UI-SELECTION-CONTENT). Both
// auto-process whatever they receive and have no player-facing recipe selection
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), so the card has no configuration group at
// all; its cycle is whichever recipe is currently in production.
class AutoProductionContent : public BufferedBuildingContent
{
Q_OBJECT
public:
AutoProductionContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent = nullptr);
protected:
void refreshConfiguration() override;
CycleInfo getCycleInfo(const Building& building) const override;
};

View File

@@ -0,0 +1,38 @@
#include "BeltContent.h"
#include <QVBoxLayout>
#include "BuildingTarget.h"
#include "ClearBeltControl.h"
#include "SelectionNames.h"
BeltContent::BeltContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
// Only an operational tile aggregates, so a card showing several is never a site;
// a single selected tile still can be one.
: SelectionContent(context,
request.buildings.size() == 1
? asConstructionSite(context, request.buildings.front())
: std::nullopt,
parent)
, m_ids(request.buildings)
{
getRuntimeLayout()->addWidget(new ClearBeltControl(context, m_ids, this));
}
void BeltContent::refreshConfiguration()
{
const BuildingTarget target = resolveBuildingTarget(getContext(), m_ids.front());
if (!target.isValid())
{
return;
}
// An aggregated selection may mix belts with tunnel ends, so it is named after the
// first tile; the count says how many are held (REQ-UI-SELECTION-AGGREGATE).
setBuildingIdentity(target.type, getBuildingTypeName(target.type));
if (m_ids.size() > 1)
{
setCountSlot(static_cast<int>(m_ids.size()));
}
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include <vector>
#include "BuildingId.h"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
// The card for a belt, a tunnel entry or a tunnel exit
// (REQ-UI-SELECTION-CONTENT). These carry no configuration and no buffers of their own;
// their card is the clear action alone (REQ-UI-BELT-CLEAR).
//
// Because that action already operates on the whole selection, several of them aggregate
// into this one card with the count in the header (REQ-UI-SELECTION-AGGREGATE) -- the
// splitter is not among them, as its output filters are per-object.
class BeltContent : public SelectionContent
{
Q_OBJECT
public:
BeltContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshConfiguration() override;
void refreshRuntime() override {}
private:
std::vector<BuildingId> m_ids;
};

View File

@@ -0,0 +1,95 @@
#include "BufferSection.h"
#include <QLabel>
#include <QVBoxLayout>
#include "Building.h"
namespace
{
// One "<item>: <count>[/<per cycle>]" 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<std::string, int>& perCycle, const std::string& itemId)
{
const std::map<std::string, int>::const_iterator it = perCycle.find(itemId);
return (it != perCycle.end()) ? it->second : 0;
}
} // namespace
BufferSection::BufferSection(QWidget* parent)
: QWidget(parent)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
m_label = new QLabel(this);
m_label->setWordWrap(true);
layout->addWidget(m_label);
}
void BufferSection::setBuffers(const Building& building,
const std::map<std::string, int>& perCycleInputs,
const std::map<std::string, int>& perCycleOutputs)
{
QString text;
if (!building.inputBuffer.counts.empty())
{
text += tr("Input: ");
for (const std::pair<const ItemType, int>& entry : building.inputBuffer.counts)
{
text += formatEntry(entry.first.id, entry.second,
findPerCycle(perCycleInputs, entry.first.id));
}
text += "\n";
}
// Output-side items are the buffered ones plus those still emerging onto the output
// belts: an emerging item still belongs to the output buffer (REQ-MAT-OUTPUT-EMERGE),
// so leaving it out would make it vanish from the panel while it animates.
std::map<std::string, int> outputCounts;
for (const Item& item : building.outputBuffer.items)
{
outputCounts[item.type.id]++;
}
for (const std::vector<BeltItemSlot>& lane : building.emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
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.
for (const std::pair<const std::string, int>& entry : perCycleOutputs)
{
outputCounts.emplace(entry.first, 0);
}
if (!outputCounts.empty())
{
text += tr("Output: ");
for (const std::pair<const std::string, int>& entry : outputCounts)
{
text += formatEntry(entry.first, entry.second,
findPerCycle(perCycleOutputs, entry.first));
}
}
m_label->setText(text.trimmed());
setVisible(!text.trimmed().isEmpty());
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include <map>
#include <string>
#include <QWidget>
struct Building;
class QLabel;
// The input and output buffer contents of one building (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.
class BufferSection : public QWidget
{
Q_OBJECT
public:
explicit BufferSection(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.
void setBuffers(const Building& building,
const std::map<std::string, int>& perCycleInputs,
const std::map<std::string, int>& perCycleOutputs);
private:
QLabel* m_label;
};

View File

@@ -0,0 +1,39 @@
#include "BufferedBuildingContent.h"
#include <QVBoxLayout>
#include "Building.h"
#include "BufferSection.h"
#include "BuildingTarget.h"
#include "FactoryQueries.h"
#include "ProductionSection.h"
#include "Simulation.h"
BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context,
BuildingId id, QWidget* parent)
: SelectionContent(context, asConstructionSite(context, id), parent)
, m_id(id)
{
m_buffers = new BufferSection(this);
m_production = new ProductionSection(this);
getRuntimeLayout()->addWidget(m_buffers);
getRuntimeLayout()->addWidget(m_production);
}
void BufferedBuildingContent::refreshRuntime()
{
const Building* building = findBuilding(getContext().sim->getFactoryState(), m_id);
if (!building)
{
// Gone under the card. SelectionPanel rebuilds on the same refresh; this only
// has to avoid reading it.
return;
}
setProductionStatusSlot(*building);
const CycleInfo cycle = getCycleInfo(*building);
m_buffers->setBuffers(*building, cycle.perCycleInputs, cycle.perCycleOutputs);
m_production->setProduction(cycle.runsProduction, *building, cycle.durationSeconds,
getContext().sim->getCurrentTick());
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <map>
#include <string>
#include "BuildingId.h"
#include "SelectionContent.h"
struct Building;
class BufferSection;
class ProductionSection;
// 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.
//
// This is implementation sharing, not a catalog entry: every concrete subclass is one
// row of the content catalog.
class BufferedBuildingContent : public SelectionContent
{
Q_OBJECT
protected:
// What one production cycle of this building consumes, produces, and takes.
struct CycleInfo
{
std::map<std::string, int> perCycleInputs;
std::map<std::string, int> perCycleOutputs;
// False when the building produces nothing at all (the Salvage Bay,
// REQ-BLD-SALVAGE-BAY) or has no recipe or schematic selected yet: the
// production section is then not shown (REQ-UI-PRODUCTION-PROGRESS).
bool runsProduction = false;
// 0 while an auto-recipe building sits between cycles, when no single recipe
// names a cycle time; the progress line then reads "idle".
double durationSeconds = 0.0;
};
BufferedBuildingContent(const SelectionContext& context, BuildingId id,
QWidget* parent);
virtual CycleInfo getCycleInfo(const Building& building) const = 0;
BuildingId getBuildingId() const { return m_id; }
void refreshRuntime() override;
private:
BuildingId m_id;
BufferSection* m_buffers;
ProductionSection* m_production;
};

View File

@@ -0,0 +1,34 @@
#include "BuildingTarget.h"
#include "FactoryQueries.h"
#include "SelectionContext.h"
#include "Simulation.h"
BuildingTarget resolveBuildingTarget(const SelectionContext& context, BuildingId id)
{
BuildingTarget target;
target.building = findBuilding(context.sim->getFactoryState(), id);
target.site = target.building
? nullptr
: findSite(context.sim->getFactoryState(), id);
if (!target.isValid())
{
return target;
}
target.type = target.building ? target.building->type : target.site->type;
target.recipeId = target.building ? target.building->recipeId : target.site->recipeId;
target.shipLayout = target.building ? target.building->shipLayout : target.site->shipLayout;
target.anchor = target.building ? target.building->anchor : target.site->anchor;
return target;
}
std::optional<BuildingId> asConstructionSite(const SelectionContext& context,
BuildingId id)
{
if (findSite(context.sim->getFactoryState(), id) != nullptr)
{
return id;
}
return std::nullopt;
}

View File

@@ -0,0 +1,45 @@
#pragma once
#include <optional>
#include <string>
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "ShipLayout.h"
struct Building;
struct ConstructionSite;
struct SelectionContext;
// One selected building id resolved to whichever of the two things it names: an
// operational building or a construction site still queued or under construction. A
// site carries the same configuration as the building it will become
// (REQ-BLD-SITE-CONFIG), so the fields both have are read out here once instead of in
// every content that shows a single building.
struct BuildingTarget
{
const Building* building = nullptr; // null while it is still a site
const ConstructionSite* site = nullptr; // null once it is built
BuildingType type = BuildingType::Miner;
std::string recipeId;
std::optional<ShipLayoutConfig> shipLayout;
QPoint anchor;
// False when the id names neither -- the object went away under the panel (it was
// deconstructed, or its site finished and its id was reused).
bool isValid() const { return building != nullptr || site != nullptr; }
};
// Resolves the id against the current factory state. The returned pointers are only
// valid until the simulation next mutates, so this is called per refresh rather than
// cached.
BuildingTarget resolveBuildingTarget(const SelectionContext& context, BuildingId id);
// The id wrapped as a construction site id when it names one, nullopt when it names an
// operational building. Every content showing a single building hands this to
// SelectionContent so the base can apply the site rule (REQ-UI-SELECTION-CARD).
std::optional<BuildingId> asConstructionSite(const SelectionContext& context,
BuildingId id);

View File

@@ -0,0 +1,60 @@
SET(HDRS
${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContext.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContentFactory.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionNames.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTarget.h
${CMAKE_CURRENT_SOURCE_DIR}/DebrisScrap.h
${CMAKE_CURRENT_SOURCE_DIR}/BufferSection.h
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.h
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.h
${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.h
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.h
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltContent.h
${CMAKE_CURRENT_SOURCE_DIR}/SplitterContent.h
${CMAKE_CURRENT_SOURCE_DIR}/MultiBuildingContent.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipContent.h
${CMAKE_CURRENT_SOURCE_DIR}/StationContent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebrisContent.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldMultiContent.h
PARENT_SCOPE
)
SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContentFactory.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionNames.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTarget.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DebrisScrap.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BufferSection.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SplitterContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MultiBuildingContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StationContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DebrisContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldMultiContent.cpp
PARENT_SCOPE
)
set(UI_INCLUDE_PATH
${UI_INCLUDE_PATH}
${CMAKE_CURRENT_SOURCE_DIR}
PARENT_SCOPE
)

View File

@@ -0,0 +1,54 @@
#include "ClearBeltControl.h"
#include <QPoint>
#include <QPushButton>
#include <QVBoxLayout>
#include "Building.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "EventManager.h"
#include "FactoryQueries.h"
#include "Simulation.h"
ClearBeltControl::ClearBeltControl(const SelectionContext& context,
const std::vector<BuildingId>& ids, QWidget* parent)
: QWidget(parent)
, m_context(context)
, m_ids(ids)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
QPushButton* button = new QPushButton(tr("Clear stuck items"), this);
layout->addWidget(button);
connect(button, &QPushButton::clicked, this, [this]() { clearSelectedTiles(); });
}
void ClearBeltControl::clearSelectedTiles() const
{
std::vector<QPoint> tiles;
for (BuildingId id : m_ids)
{
const Building* building = findBuilding(m_context.sim->getFactoryState(), id);
if (building && isBeltSubsystemType(building->type))
{
for (const QPoint& cell : building->bodyCells)
{
tiles.push_back(cell);
}
}
}
if (tiles.empty())
{
return;
}
std::shared_ptr<ClearBeltTilesCommand> command =
std::make_shared<ClearBeltTilesCommand>();
command->tiles = std::move(tiles);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include <vector>
#include <QWidget>
#include "BuildingId.h"
#include "SelectionContext.h"
// The "Clear stuck items" action of the card's runtime group (REQ-UI-BELT-CLEAR): it
// removes every item from the selected belt, splitter and tunnel tiles, which is how a
// stalled line is resolved.
//
// It acts on the whole selection rather than on one tile, which is why a selection of
// belts and tunnel ends aggregates into a single card instead of a count summary
// (REQ-UI-SELECTION-AGGREGATE), and why the count summary shows the action too when a
// belt is among the selected buildings.
class ClearBeltControl : public QWidget
{
Q_OBJECT
public:
ClearBeltControl(const SelectionContext& context,
const std::vector<BuildingId>& ids, QWidget* parent = nullptr);
private:
void clearSelectedTiles() const;
SelectionContext m_context;
std::vector<BuildingId> m_ids;
};

View File

@@ -0,0 +1,30 @@
#include "DebrisContent.h"
#include <QLabel>
#include <QVBoxLayout>
#include "DebrisScrap.h"
#include "Simulation.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);
setIdentity(QPixmap(), tr("Debris"));
if (m_debris.size() > 1)
{
setCountSlot(static_cast<int>(m_debris.size()));
}
}
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)));
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class QLabel;
// The card for selected debris (REQ-UI-DEBRIS-PANEL): the scrap still left in it.
//
// It is the field category's aggregating content (REQ-UI-SELECTION-AGGREGATE): several
// pieces of debris show this same card with the count in the header and their scrap
// summed, because that is the one value the card holds and it adds up.
class DebrisContent : public SelectionContent
{
Q_OBJECT
public:
DebrisContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
std::vector<entt::entity> m_debris;
QLabel* m_scrapLabel;
};

View File

@@ -0,0 +1,18 @@
#include "DebrisScrap.h"
#include <algorithm>
#include "DebrisSystem.h"
int sumDebrisScrap(const EntityAdmin& admin, const std::vector<entt::entity>& debris)
{
int total = 0;
for (const DebrisInfo& info : getAllDebrisInfo(admin))
{
if (std::find(debris.begin(), debris.end(), info.entity) != debris.end())
{
total += info.amount;
}
}
return total;
}

View File

@@ -0,0 +1,13 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
class EntityAdmin;
// Remaining scrap summed over the given debris, skipping pieces that have already been
// collected or despawned (REQ-RES-DEBRIS-DROP). Shared by the debris card and the field
// count summary, which show the same total in two shapes (REQ-UI-DEBRIS-PANEL,
// REQ-UI-FIELD-MULTI-SELECTION).
int sumDebrisScrap(const EntityAdmin& admin, const std::vector<entt::entity>& debris);

View File

@@ -0,0 +1,91 @@
#include "FieldMultiContent.h"
#include <map>
#include <string>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
#include "DebrisScrap.h"
#include "DisplayName.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "ShipIdentityComponent.h"
#include "Simulation.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_summaryLabel = new QLabel(this);
m_summaryLabel->setWordWrap(true);
getRuntimeLayout()->addWidget(m_summaryLabel);
setIdentity(QPixmap(), tr("Mixed selection"));
setCountSlot(static_cast<int>(m_actors.size() + m_debris.size()));
}
void FieldMultiContent::refreshRuntime()
{
EntityAdmin& admin = getContext().sim->getAdmin();
// Grouped by faction, kind and ship schematic, in the order the groups are first
// seen (REQ-UI-FIELD-MULTI-SELECTION).
std::vector<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity actor : m_actors)
{
if (!admin.isValid(actor)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(actor)
&& admin.get<FactionComponent>(actor).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(actor))
{
const std::string& id = admin.get<ShipIdentityComponent>(actor).schematicId;
const QString name = QString::fromStdString(toDisplayName(id));
key = (isEnemy ? QStringLiteral("ship:enemy:")
: QStringLiteral("ship:player:"))
+ QString::fromStdString(id);
label = isEnemy ? tr("Enemy %1").arg(name) : name;
}
else if (admin.hasAll<StationBodyComponent>(actor))
{
key = isEnemy ? QStringLiteral("station:enemy")
: QStringLiteral("station:player");
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
}
else
{
continue;
}
if (counts.find(key) == counts.end())
{
keys.push_back(key);
labels[key] = label;
}
counts[key] += 1;
}
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_debris.empty())
{
lines << tr("Debris x %1").arg(static_cast<int>(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));
}
m_summaryLabel->setText(lines.join('\n'));
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class QLabel;
// 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
// debris. A count per type, and the debris' summed scrap when debris is part of it.
class FieldMultiContent : public SelectionContent
{
Q_OBJECT
public:
FieldMultiContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
std::vector<entt::entity> m_actors;
std::vector<entt::entity> m_debris;
QLabel* m_summaryLabel;
};

View File

@@ -0,0 +1,42 @@
#include "HqContent.h"
#include <QLabel>
#include <QVBoxLayout>
#include "EntityAdmin.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "SelectionNames.h"
#include "Simulation.h"
HqContent::HqContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent)
// The HQ is placed before the game starts and can never be deconstructed
// (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);
setBuildingIdentity(BuildingType::Hq, getBuildingTypeName(BuildingType::Hq));
}
void HqContent::refreshRuntime()
{
m_stockLabel->setText(
tr("Building blocks: %1").arg(getContext().sim->getBuildingBlocksStock()));
// The HQ's health lives on its proxy entity, not on the building
// (REQ-HQ-STATS, REQ-UI-HP-BARS).
EntityAdmin& admin = getContext().sim->getAdmin();
admin.forEach<HqProxyComponent, HealthComponent>(
[this](entt::entity /*entity*/, const HqProxyComponent& /*proxy*/,
const HealthComponent& health)
{
m_hpLabel->setText(tr("HP: %1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f)));
});
}

View File

@@ -0,0 +1,29 @@
#pragma once
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class QLabel;
// 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.
//
// The stock is not a buffer: blocks delivered by belt go straight into the global stock
// (REQ-HQ-BELT-INPUT), which is exactly why the card shows it -- it is what tells the
// player to route blocks here. The HP comes from the HQ's proxy entity rather than from
// the building, since that is where its health lives.
class HqContent : public SelectionContent
{
Q_OBJECT
public:
HqContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
QLabel* m_stockLabel;
QLabel* m_hpLabel;
};

View File

@@ -0,0 +1,90 @@
#include "MultiBuildingContent.h"
#include <map>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
#include "Building.h"
#include "ClearBeltControl.h"
#include "FactoryQueries.h"
#include "GameConfig.h"
#include "SelectionNames.h"
#include "Simulation.h"
MultiBuildingContent::MultiBuildingContent(const SelectionContext& context,
const SelectionRequest& request,
QWidget* parent)
: 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);
// 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.
bool hasBeltTile = false;
for (BuildingId id : m_ids)
{
const Building* building = findBuilding(context.sim->getFactoryState(), id);
if (building && isBeltSubsystemType(building->type))
{
hasBeltTile = true;
break;
}
}
if (hasBeltTile)
{
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<int>(m_ids.size())));
buildSummary();
}
void MultiBuildingContent::buildSummary()
{
std::map<BuildingType, int> counts;
for (BuildingId id : m_ids)
{
const Building* building =
findBuilding(getContext().sim->getFactoryState(), id);
if (building)
{
counts[building->type]++;
continue;
}
const ConstructionSite* site =
findSite(getContext().sim->getFactoryState(), id);
if (site)
{
counts[site->type]++;
}
}
QStringList lines;
int totalCost = 0;
for (const std::pair<const BuildingType, int>& entry : counts)
{
lines << tr("%1 x %2").arg(getBuildingTypeName(entry.first)).arg(entry.second);
// Only player-placeable buildings count toward the total; the HQ and defence
// stations are excluded (REQ-UI-MULTI-SELECTION). A construction site counts at
// its type's full placement cost regardless of progress.
const BuildingDef* def =
getContext().config->buildings.findBuildingDef(entry.first);
if (def && def->playerPlaceable)
{
totalCost += def->cost * entry.second;
}
}
m_countsLabel->setText(lines.join('\n'));
m_totalCostLabel->setText(tr("Total: %1 Building Blocks").arg(totalCost));
}

View File

@@ -0,0 +1,34 @@
#pragma once
#include <vector>
#include "BuildingId.h"
#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
// total building block cost of the selection. No per-building detail is shown.
class MultiBuildingContent : public SelectionContent
{
Q_OBJECT
public:
MultiBuildingContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent = nullptr);
protected:
// The summary is fixed for a given selection -- how many of each type were selected,
// and what they cost -- so it is built once and has nothing to keep current. A
// building leaving the selection re-publishes it and rebuilds this card.
void refreshRuntime() override {}
private:
void buildSummary();
std::vector<BuildingId> m_ids;
QLabel* m_countsLabel;
QLabel* m_totalCostLabel;
};

View File

@@ -0,0 +1,53 @@
#include "ProductionSection.h"
#include <algorithm>
#include <QLabel>
#include <QVBoxLayout>
#include "Building.h"
ProductionSection::ProductionSection(QWidget* parent)
: QWidget(parent)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
m_label = new QLabel(this);
layout->addWidget(m_label);
}
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
// (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<int>(
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();
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include <QWidget>
#include "Tick.h"
struct Building;
class QLabel;
// The cycle time and production progress of one building (REQ-UI-PRODUCTION-PROGRESS).
//
// Reading the progress off an active cycle is the same for every building type, so it
// happens here; working out how long that cycle is differs per type (a recipe's
// duration, a schematic's production time plus its modules'), so the owning content
// supplies it.
class ProductionSection : public QWidget
{
Q_OBJECT
public:
explicit ProductionSection(QWidget* parent = nullptr);
// runsProduction false hides the section entirely -- the building produces nothing
// (a Salvage Bay) or has no recipe or schematic selected yet. When it is true but
// durationSeconds is 0 or less, the building is between cycles with no single recipe
// to name a cycle time for (an idle auto-recipe building), so the progress line
// reads "idle" and the cycle time is left out.
void setProduction(bool runsProduction, const Building& building,
double durationSeconds, Tick currentTick);
private:
QLabel* m_label;
};

View File

@@ -0,0 +1,59 @@
#include "RecipeProductionContent.h"
#include <QVBoxLayout>
#include "Building.h"
#include "BuildingTarget.h"
#include "GameConfig.h"
#include "RecipeSelectionControl.h"
#include "SelectionNames.h"
RecipeProductionContent::RecipeProductionContent(const SelectionContext& context,
const SelectionRequest& request,
QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
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).
m_recipeControl = new RecipeSelectionControl(context, getBuildingId(), target.type,
this);
getConfigurationLayout()->addWidget(m_recipeControl);
}
void RecipeProductionContent::refreshConfiguration()
{
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
{
CycleInfo info;
const RecipeDef* recipe = building.recipeId.empty()
? nullptr
: getContext().config->recipes.findRecipeDef(building.recipeId, building.type);
if (!recipe)
{
return info;
}
for (const RecipeIngredient& ingredient : recipe->inputs)
{
info.perCycleInputs[ingredient.item] = ingredient.amount;
}
for (const RecipeOutput& output : recipe->outputs)
{
info.perCycleOutputs[output.item] = output.amount;
}
info.runsProduction = true;
info.durationSeconds = recipe->durationSeconds;
return info;
}

View File

@@ -0,0 +1,26 @@
#pragma once
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
class RecipeSelectionControl;
// The card for a Miner or an Assembler (REQ-UI-SELECTION-CONTENT): a player-selected
// recipe, plus the buffers and production progress every buffered building shows. The
// two share a card because both run one selected recipe; a Miner simply has no inputs,
// so its input buffer section shows nothing of its own accord.
class RecipeProductionContent : public BufferedBuildingContent
{
Q_OBJECT
public:
RecipeProductionContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent = nullptr);
protected:
void refreshConfiguration() override;
CycleInfo getCycleInfo(const Building& building) const override;
private:
RecipeSelectionControl* m_recipeControl;
};

View File

@@ -0,0 +1,57 @@
#include "RecipeSelectionControl.h"
#include <vector>
#include <QPushButton>
#include <QVBoxLayout>
#include "EventManager.h"
#include "RecipeSelectionDialog.h"
#include "RecipeSelectionRequestedEvent.h"
#include "Simulation.h"
RecipeSelectionControl::RecipeSelectionControl(const SelectionContext& context,
BuildingId id, BuildingType type,
QWidget* parent)
: QWidget(parent)
, m_context(context)
, m_id(id)
, m_type(type)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
m_button = new QPushButton(this);
layout->addWidget(m_button);
connect(m_button, &QPushButton::clicked, this, [this]() {
// Sent synchronously: MainWindow pauses the game, runs the modal dialog, and
// restores the speed before this returns. The chosen recipe is only enqueued as
// a command though, and drains on a later frame -- so the caption follows from
// the next refresh, not from here.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<RecipeSelectionRequestedEvent>(m_id));
});
}
void RecipeSelectionControl::setRecipeId(const std::string& recipeId)
{
const std::vector<RecipeSelectionOption> options =
buildRecipeSelectionOptions(m_type, *m_context.sim, *m_context.config);
for (const RecipeSelectionOption& option : options)
{
if (option.id == recipeId && !option.id.empty())
{
m_button->setText(option.caption);
m_button->setToolTip(option.tooltip);
return;
}
}
m_button->setText(m_type == BuildingType::Shipyard
? tr("Select schematic")
: tr("Select recipe"));
m_button->setToolTip(QString());
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include <string>
#include <QWidget>
#include "BuildingId.h"
#include "BuildingType.h"
#include "SelectionContext.h"
class QPushButton;
// The recipe/schematic selection control of the card's configuration group: one button
// captioned with the current selection that opens the modal selection dialog
// (REQ-UI-SELECT-BUTTON, REQ-UI-CONFIG-INLINE). Used by the Miner and Assembler for
// their recipe and by the Shipyard for its schematic; the placeholder caption is the
// only difference between the two.
//
// The control does not apply the choice itself: it asks for the dialog and the choice
// arrives back as a command, so the caption follows from the next refresh rather than
// from the click.
class RecipeSelectionControl : public QWidget
{
Q_OBJECT
public:
RecipeSelectionControl(const SelectionContext& context, BuildingId id,
BuildingType type, QWidget* parent = nullptr);
// Re-reads the caption and tooltip for the currently configured recipe id.
void setRecipeId(const std::string& recipeId);
private:
SelectionContext m_context;
BuildingId m_id;
BuildingType m_type;
QPushButton* m_button;
};

View File

@@ -0,0 +1,254 @@
#include "SelectionContent.h"
#include <algorithm>
#include <string>
#include <QFont>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QRectF>
#include <QVBoxLayout>
#include "BuildingIconCache.h"
#include "FactoryQueries.h"
#include "GameConfig.h"
#include "ProductionRules.h"
#include "Simulation.h"
#include "Tick.h"
#include "VisualsConfig.h"
namespace
{
// Size of the identity chip in the card header, in device-independent pixels. Smaller
// than the build button's 32 px chip: here it labels a line of text rather than being
// 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<int>(kStatusDotSizePx * dpr),
static_cast<int>(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
SelectionContent::SelectionContent(const SelectionContext& context,
std::optional<BuildingId> constructionSiteId,
QWidget* parent)
: QWidget(parent)
, m_context(context)
, m_siteId(constructionSiteId)
, m_constructionLabel(nullptr)
{
QVBoxLayout* cardLayout = new QVBoxLayout(this);
cardLayout->setContentsMargins(0, 0, 0, 0);
cardLayout->setSpacing(kCardSpacingPx);
// Header: identity symbol, name, and the right slot pushed to the far edge
// (REQ-UI-SELECTION-CARD).
QWidget* header = new QWidget(this);
QHBoxLayout* headerLayout = new QHBoxLayout(header);
headerLayout->setContentsMargins(0, 0, 0, 0);
headerLayout->setSpacing(kHeaderSpacingPx);
m_symbolLabel = new QLabel(header);
m_symbolLabel->hide();
m_nameLabel = new QLabel(header);
QFont nameFont = m_nameLabel->font();
nameFont.setBold(true);
m_nameLabel->setFont(nameFont);
m_slotDot = new QLabel(header);
m_slotDot->hide();
m_slotLabel = new QLabel(header);
m_slotLabel->hide();
headerLayout->addWidget(m_symbolLabel);
headerLayout->addWidget(m_nameLabel);
headerLayout->addStretch(1);
headerLayout->addWidget(m_slotDot);
headerLayout->addWidget(m_slotLabel);
cardLayout->addWidget(header);
m_configurationGroup = new QWidget(this);
QVBoxLayout* configurationLayout = new QVBoxLayout(m_configurationGroup);
configurationLayout->setContentsMargins(0, 0, 0, 0);
configurationLayout->setSpacing(kCardSpacingPx);
cardLayout->addWidget(m_configurationGroup);
m_runtimeGroup = new QWidget(this);
QVBoxLayout* runtimeLayout = new QVBoxLayout(m_runtimeGroup);
runtimeLayout->setContentsMargins(0, 0, 0, 0);
runtimeLayout->setSpacing(kCardSpacingPx);
cardLayout->addWidget(m_runtimeGroup);
if (m_siteId.has_value())
{
// A site has no buffers and runs no production cycle, so whatever the subclass
// puts into the runtime group is not shown at all; the construction section
// 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);
setSlot(QColor(), tr("constructing"));
}
}
SelectionContent::~SelectionContent() = default;
void SelectionContent::refresh()
{
refreshConfiguration();
if (m_siteId.has_value())
{
refreshConstruction();
return;
}
refreshRuntime();
}
QVBoxLayout* SelectionContent::getConfigurationLayout()
{
return static_cast<QVBoxLayout*>(m_configurationGroup->layout());
}
QVBoxLayout* SelectionContent::getRuntimeLayout()
{
return static_cast<QVBoxLayout*>(m_runtimeGroup->layout());
}
void SelectionContent::setIdentity(const QPixmap& symbol, const QString& name)
{
m_symbolLabel->setPixmap(symbol);
m_symbolLabel->setVisible(!symbol.isNull());
m_nameLabel->setText(name);
}
void SelectionContent::setBuildingIdentity(BuildingType type, const QString& name)
{
const std::string iconName = buildingTypeId(type);
setIdentity(m_context.buildingIcons->getChip(iconName, kSymbolSizePx), name);
}
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());
}
void SelectionContent::setCountSlot(int count)
{
setSlot(QColor(), tr("x%1").arg(count));
}
void SelectionContent::clearSlot()
{
setSlot(QColor(), QString());
}
void SelectionContent::setProductionStatusSlot(const Building& building)
{
// The classification is the simulation's, so the panel and the world's status light
// can never disagree (REQ-UI-SELECTION-STATUS, REQ-UI-STATUS-LIGHT).
const std::optional<ProductionStatus> status =
getProductionStatus(*m_context.config, building);
if (!status.has_value())
{
clearSlot();
return;
}
const StatusLightVisuals& colors = m_context.visuals->statusLight;
// The Salvage Bay has no recipe and no cycle: its two states say whether it is
// holding scrap, not whether it is producing (REQ-BLD-SALVAGE-BAY).
const bool isSalvageBay = (building.type == BuildingType::SalvageBay);
switch (*status)
{
case ProductionStatus::Unconfigured:
setSlot(colors.grey, tr("no recipe"));
break;
case ProductionStatus::Producing:
setSlot(colors.green, isSalvageBay ? tr("holding scrap") : tr("producing"));
break;
case ProductionStatus::Starved:
setSlot(colors.red, isSalvageBay ? tr("empty") : tr("missing input"));
break;
case ProductionStatus::Blocked:
setSlot(colors.yellow, tr("output full"));
break;
}
}
void SelectionContent::refreshConstruction()
{
const ConstructionSite* site =
findSite(m_context.sim->getFactoryState(), *m_siteId);
if (!site)
{
// The site finished or was removed under the card. SelectionPanel rebuilds on
// the same refresh, so this only has to avoid reading a dead site.
return;
}
QString progress;
if (site->completesAt == 0)
{
progress = tr("Queued");
}
else
{
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<int>(
std::max(Tick(0), std::min(duration, elapsed)) * 100 / duration);
progress = tr("Construction: %1%").arg(percent);
}
else
{
progress = tr("Building...");
}
}
m_constructionLabel->setText(progress);
}

View File

@@ -0,0 +1,101 @@
#pragma once
#include <optional>
#include <QColor>
#include <QPixmap>
#include <QString>
#include <QWidget>
#include "BuildingId.h"
#include "BuildingType.h"
#include "SelectionContext.h"
struct Building;
class QLabel;
class QVBoxLayout;
// 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
// puts into the two groups; which content is shown for which selection is decided in
// SelectionContentFactory (REQ-UI-SELECTION-CONTENT).
//
// The card has three parts, top to bottom:
// * the header -- identity symbol, name, and one optional right slot (a status
// indicator, a ship's behavior, or an object count);
// * the configuration group -- controls that change how the object is set up;
// * the runtime group -- what the object is currently doing.
//
// A construction site keeps its configuration group and has its whole runtime group
// replaced by the construction section (REQ-BLD-SITE-CONFIG). That rule lives here and
// nowhere else: a subclass fills both groups unconditionally in its constructor and
// never asks whether it is showing a site.
class SelectionContent : public QWidget
{
Q_OBJECT
public:
~SelectionContent() override;
// Re-reads the live values behind the card. It never changes the card's structure:
// a change that would (a site finishing, the selection changing) is a rebuild, and
// SelectionPanel owns that decision.
void refresh();
protected:
// constructionSiteId is set only while the card shows a construction site, in which
// case this base builds and drives the construction section in place of whatever the
// subclass puts into the runtime group.
SelectionContent(const SelectionContext& context,
std::optional<BuildingId> constructionSiteId,
QWidget* parent);
// Live values of the runtime group. Not called while the card shows a construction
// site, which has neither buffers nor a production cycle (REQ-BLD-SITE-CONFIG).
virtual void refreshRuntime() = 0;
// Live values of the configuration group. Called for a site too, because a site is
// configured exactly like the building it will become (REQ-BLD-SITE-CONFIG).
virtual void refreshConfiguration() {}
const SelectionContext& getContext() const { return m_context; }
// The two group layouts a subclass adds its parts to, in its constructor.
QVBoxLayout* getConfigurationLayout();
QVBoxLayout* getRuntimeLayout();
void setIdentity(const QPixmap& symbol, const QString& name);
// Header symbol for a building type: its chip icon, or nothing when the icon file is
// missing -- which is not an error (REQ-UI-BUILD-ICON, REQ-UI-SELECTION-CARD).
void setBuildingIdentity(BuildingType type, const QString& name);
// Fills the header's right slot. A null dot color leaves the dot off, for the slots
// that are a plain caption (a construction site, a ship's behavior).
void setSlot(const QColor& dotColor, const QString& caption);
// "x<count>", for an aggregated multi-selection (REQ-UI-SELECTION-AGGREGATE).
void setCountSlot(int count);
void clearSlot();
// Fills the right slot from the building's production status, mapped to the same
// colors and states the world's status light uses (REQ-UI-SELECTION-STATUS). Leaves
// the slot empty for a type that has no status light.
void setProductionStatusSlot(const Building& building);
private:
void refreshConstruction();
SelectionContext m_context;
// Set while this card shows a construction site rather than a finished object.
std::optional<BuildingId> m_siteId;
QLabel* m_symbolLabel;
QLabel* m_nameLabel;
QLabel* m_slotDot;
QLabel* m_slotLabel;
QWidget* m_configurationGroup;
QWidget* m_runtimeGroup;
// Replaces the runtime group while this card shows a construction site; null
// otherwise.
QLabel* m_constructionLabel;
};

View File

@@ -0,0 +1,187 @@
#include "SelectionContentFactory.h"
#include "AutoProductionContent.h"
#include "BeltContent.h"
#include "DebrisContent.h"
#include "FactoryQueries.h"
#include "FieldMultiContent.h"
#include "HqContent.h"
#include "MultiBuildingContent.h"
#include "RecipeProductionContent.h"
#include "ShipContent.h"
#include "ShipIdentityComponent.h"
#include "ShipyardContent.h"
#include "SplitterContent.h"
#include "StationBodyComponent.h"
#include "StationContent.h"
#include "StorageContent.h"
#include "Simulation.h"
namespace
{
// The catalog row a single building type belongs to (REQ-UI-SELECTION-CONTENT).
SelectionContentKind getKindForType(BuildingType type)
{
switch (type)
{
case BuildingType::Miner:
case BuildingType::Assembler:
return SelectionContentKind::RecipeProduction;
case BuildingType::Smelter:
case BuildingType::ReprocessingPlant:
return SelectionContentKind::AutoProduction;
case BuildingType::Shipyard:
return SelectionContentKind::Shipyard;
case BuildingType::SalvageBay:
return SelectionContentKind::Storage;
case BuildingType::Hq:
return SelectionContentKind::Hq;
case BuildingType::Belt:
case BuildingType::TunnelEntry:
case BuildingType::TunnelExit:
return SelectionContentKind::Belt;
case BuildingType::Splitter:
return SelectionContentKind::Splitter;
case BuildingType::PlayerDefenceStation:
case BuildingType::EnemyDefenceStation:
// Defence stations are field objects backed by entities; these two enum
// values exist only for cost and visuals lookup and never reach the panel as
// a building selection. The count summary is the harmless fallback.
return SelectionContentKind::MultiBuilding;
}
return SelectionContentKind::MultiBuilding;
}
// A belt, tunnel entry or tunnel exit -- the types whose card is the clear action alone
// and therefore aggregates (REQ-UI-SELECTION-AGGREGATE). The splitter is deliberately
// excluded: it carries per-object output filters, which have no aggregate.
bool isAggregatableBeltType(BuildingType type)
{
return type == BuildingType::Belt
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit;
}
ContentKey chooseBuildingContent(const SelectionRequest& request, Simulation& sim)
{
const FactoryState& state = sim.getFactoryState();
if (request.buildings.size() == 1)
{
const BuildingId id = request.buildings.front();
const Building* building = findBuilding(state, id);
if (building)
{
return { getKindForType(building->type), false };
}
const ConstructionSite* site = findSite(state, id);
if (site)
{
return { getKindForType(site->type), true };
}
// The building went away under the panel; the selection is stale and there is
// nothing to show (REQ-UI-EMPTY-SELECTION).
return {};
}
// Several buildings aggregate into one card only when every part of that card
// aggregates (REQ-UI-SELECTION-AGGREGATE). Construction sites are excluded from the
// belt case: a site's card is its own construction progress, which several sites
// cannot share.
bool allAggregatableBelts = true;
for (BuildingId id : request.buildings)
{
const Building* building = findBuilding(state, id);
if (!building || !isAggregatableBeltType(building->type))
{
allAggregatableBelts = false;
break;
}
}
if (allAggregatableBelts)
{
return { SelectionContentKind::Belt, false };
}
return { SelectionContentKind::MultiBuilding, false };
}
ContentKey chooseFieldContent(const SelectionRequest& request, Simulation& sim)
{
// A full single-object card is shown for a lone actor, and for debris whether one
// piece or several -- debris is the field category's other aggregating content
// (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-SELECTION-AGGREGATE).
if (request.actors.size() == 1 && request.debris.empty())
{
EntityAdmin& admin = sim.getAdmin();
const entt::entity actor = request.actors.front();
if (admin.isValid(actor) && admin.hasAll<ShipIdentityComponent>(actor))
{
return { SelectionContentKind::Ship, false };
}
if (admin.isValid(actor) && admin.hasAll<StationBodyComponent>(actor))
{
return { SelectionContentKind::Station, false };
}
return {};
}
if (request.actors.empty() && !request.debris.empty())
{
return { SelectionContentKind::Debris, false };
}
return { SelectionContentKind::FieldMulti, false };
}
} // namespace
ContentKey chooseContent(const SelectionRequest& request, Simulation& sim)
{
if (request.isEmpty())
{
return {};
}
// Buildings win, so a non-empty building selection is the whole selection
// (REQ-UI-SELECTION-CATEGORIES).
if (!request.buildings.empty())
{
return chooseBuildingContent(request, sim);
}
return chooseFieldContent(request, sim);
}
SelectionContent* createContent(const ContentKey& key, const SelectionRequest& request,
const SelectionContext& context, QWidget* parent)
{
switch (key.kind)
{
case SelectionContentKind::None:
return nullptr;
case SelectionContentKind::RecipeProduction:
return new RecipeProductionContent(context, request, parent);
case SelectionContentKind::AutoProduction:
return new AutoProductionContent(context, request, parent);
case SelectionContentKind::Shipyard:
return new ShipyardContent(context, request, parent);
case SelectionContentKind::Storage:
return new StorageContent(context, request, parent);
case SelectionContentKind::Hq:
return new HqContent(context, request, parent);
case SelectionContentKind::Belt:
return new BeltContent(context, request, parent);
case SelectionContentKind::Splitter:
return new SplitterContent(context, request, parent);
case SelectionContentKind::MultiBuilding:
return new MultiBuildingContent(context, request, parent);
case SelectionContentKind::Ship:
return new ShipContent(context, request, parent);
case SelectionContentKind::Station:
return new StationContent(context, request, parent);
case SelectionContentKind::Debris:
return new DebrisContent(context, request, parent);
case SelectionContentKind::FieldMulti:
return new FieldMultiContent(context, request, parent);
}
return nullptr;
}

View File

@@ -0,0 +1,70 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
#include "BuildingId.h"
#include "SelectionContext.h"
class QWidget;
class SelectionContent;
class Simulation;
// What is currently selected, in the selection panel's terms. The two categories are
// mutually exclusive (REQ-UI-SELECTION-CATEGORIES): either buildings is non-empty, or
// actors and/or debris are, never both.
struct SelectionRequest
{
std::vector<BuildingId> buildings;
std::vector<entt::entity> actors;
std::vector<entt::entity> debris;
bool isEmpty() const
{
return buildings.empty() && actors.empty() && debris.empty();
}
};
// One entry of the content catalog (REQ-UI-SELECTION-CONTENT). Selections that share an
// entry get the same content and differ only in the name and symbol in the header.
enum class SelectionContentKind
{
None, // nothing selected: the panel hides itself entirely
RecipeProduction, // Miner, Assembler
AutoProduction, // Smelter, Reprocessing Plant
Shipyard,
Storage, // Salvage Bay
Hq,
Belt, // Belt, Tunnel Entry, Tunnel Exit
Splitter,
MultiBuilding,
Ship,
Station,
Debris,
FieldMulti,
};
// Identifies the card on screen. The panel rebuilds when this changes and only refreshes
// otherwise, so a construction site finishing swaps the card while a progress counter
// running does not.
struct ContentKey
{
SelectionContentKind kind = SelectionContentKind::None;
bool isSite = false;
bool operator==(const ContentKey& other) const
{
return kind == other.kind && isSite == other.isSite;
}
bool operator!=(const ContentKey& other) const { return !(*this == other); }
};
// The card this selection calls for. This is where REQ-UI-SELECTION-AGGREGATE is
// decided: a multi-selection that aggregates resolves to the single-object kind, and
// everything else to one of the two count summaries.
ContentKey chooseContent(const SelectionRequest& request, Simulation& sim);
// Builds the card. Returns nullptr for SelectionContentKind::None.
SelectionContent* createContent(const ContentKey& key, const SelectionRequest& request,
const SelectionContext& context, QWidget* parent);

View File

@@ -0,0 +1,29 @@
#pragma once
struct GameConfig;
struct VisualsConfig;
class BuildingIconCache;
class ItemIconCache;
class Simulation;
// Everything a selection panel content reads that is not the selection itself: the
// simulation it queries live values from, the immutable config, and the window-wide
// rendering resources. Bundled so a content's constructor stays short and adding a
// shared resource does not touch every content (REQ-UI-SELECTION-CONTENT).
//
// Nothing here is owned: the whole struct is a view onto objects living in MainWindow
// and must not outlive it. Passed by const reference and copied into each content.
struct SelectionContext
{
Simulation* sim = nullptr;
const GameConfig* config = nullptr;
const VisualsConfig* visuals = nullptr;
ItemIconCache* itemIcons = nullptr;
BuildingIconCache* buildingIcons = nullptr;
// Whether debug draw is currently on (REQ-UI-DEBUG-DRAW), which the ship card shows
// its threat cost under (REQ-UI-SHIP-STATS-PANEL). A pointer rather than a copy
// because cards outlive a toggle: the panel owns the flag, so a card reading it per
// refresh always sees the current value without subscribing to the event itself.
const bool* debugDrawEnabled = nullptr;
};

View File

@@ -0,0 +1,16 @@
#include "SelectionNames.h"
#include <QObject>
#include "DisplayName.h"
QString getBuildingTypeName(BuildingType type)
{
// "Hq" would read as a word rather than an acronym through the generic conversion,
// and the player's own base deserves naming as such.
if (type == BuildingType::Hq)
{
return QObject::tr("Player HQ");
}
return QString::fromStdString(toDisplayName(buildingTypeId(type)));
}

View File

@@ -0,0 +1,10 @@
#pragma once
#include <QString>
#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);

View File

@@ -0,0 +1,62 @@
#include "ShipContent.h"
#include <QVBoxLayout>
#include "EntityAdmin.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
#include "Simulation.h"
#include "ThreatCostCalculator.h"
ShipContent::ShipContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, std::nullopt, parent)
, m_entity(request.actors.front())
{
m_statsPanel = new ShipStatsPanel(context.config, this);
getRuntimeLayout()->addWidget(m_statsPanel);
EntityAdmin& admin = context.sim->getAdmin();
if (admin.isValid(m_entity) && admin.hasAll<ShipIdentityComponent>(m_entity))
{
setIdentity(QPixmap(), tr("Ship: %1").arg(QString::fromStdString(
admin.get<ShipIdentityComponent>(m_entity).schematicId)));
}
}
void ShipContent::refreshRuntime()
{
EntityAdmin& admin = getContext().sim->getAdmin();
if (!admin.isValid(m_entity) || !admin.hasAll<HealthComponent>(m_entity))
{
// The ship died or despawned. GameWorldView prunes it from the selection and
// re-emits (REQ-UI-ENTITY-CLICK-SELECT), which rebuilds the card; this only has
// to avoid reading it in the meantime.
return;
}
const HealthComponent& health = admin.get<HealthComponent>(m_entity);
if (health.hp <= 0.0f)
{
return;
}
const ShipStats stats = buildShipStatsFromEntity(admin, m_entity);
m_statsPanel->refreshFromLive(stats, health.hp);
m_statsPanel->setBehavior(admin.get<SelectedBehaviorComponent>(m_entity).winner);
m_statsPanel->setDebugDrawEnabled(*getContext().debugDrawEnabled);
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(m_entity);
const ShipDef* schematicDef =
getContext().config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
m_statsPanel->setThreatCost(calculateShipThreatCost(
getContext().config->threatCosts, *getContext().config,
schematicDef->id, schematicDef->defaultModules));
}
}

View File

@@ -0,0 +1,28 @@
#pragma once
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class ShipStatsPanel;
// The card for one selected ship (REQ-UI-SHIP-STATS-PANEL): its live hull stats and the
// summaries of the capability modules it carries, computed from what is actually
// installed (REQ-MOD-STAT-CALC). Applies to player and enemy ships alike
// (REQ-UI-ENTITY-CLICK-SELECT).
class ShipContent : public SelectionContent
{
Q_OBJECT
public:
ShipContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
entt::entity m_entity;
ShipStatsPanel* m_statsPanel;
};

View File

@@ -0,0 +1,103 @@
#include "ShipyardContent.h"
#include <QPushButton>
#include <QVBoxLayout>
#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 "ShipLayoutPreview.h"
ShipyardContent::ShipyardContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
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);
const BuildingId id = getBuildingId();
connect(m_configureButton, &QPushButton::clicked, this, [id]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<LayoutDialogRequestedEvent>(id));
});
}
void ShipyardContent::refreshConfiguration()
{
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
// enabled once a schematic is selected (REQ-MOD-UI-PREVIEW). The schematic arrives
// by queued command, so this refresh is what picks it up rather than the click that
// chose it.
const ShipDef* shipDef = target.recipeId.empty()
? nullptr
: getContext().config->ships.findShipDef(target.recipeId);
const bool hasSchematic = shipDef && !shipDef->layout.empty();
if (hasSchematic)
{
m_layoutPreview->setShipAndLayout(
shipDef->layout,
target.shipLayout.has_value() ? *target.shipLayout : ShipLayoutConfig(),
&getContext().config->modules);
}
else
{
m_layoutPreview->showPlaceholder();
}
m_layoutPreview->setEnabled(hasSchematic);
m_configureButton->setEnabled(hasSchematic);
}
BufferedBuildingContent::CycleInfo ShipyardContent::getCycleInfo(
const Building& building) const
{
CycleInfo info;
const ShipDef* shipDef = building.recipeId.empty()
? nullptr
: getContext().config->ships.findShipDef(building.recipeId);
if (!shipDef)
{
return info;
}
// 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.durationSeconds = shipDef->schematic.productionTimeSeconds;
if (building.shipLayout.has_value())
{
for (const PlacedModule& placed : building.shipLayout->placedModules)
{
const ModuleDef* moduleDef =
getContext().config->modules.findModuleDef(placed.moduleId);
if (moduleDef)
{
info.durationSeconds += moduleDef->productionTimeSeconds;
}
}
}
info.runsProduction = true;
return info;
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
class QPushButton;
class RecipeSelectionControl;
class ShipLayoutPreview;
// The card for a Shipyard (REQ-UI-SELECTION-CONTENT): the schematic selection, the
// module layout preview and its Configure button (REQ-MOD-UI-PREVIEW), plus the buffers
// and production progress every buffered building shows.
//
// It is the one card whose cycle is not a recipe: a shipyard's materials and production
// time are its schematic's plus every placed module's (REQ-BLD-SHIPYARD).
class ShipyardContent : public BufferedBuildingContent
{
Q_OBJECT
public:
ShipyardContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshConfiguration() override;
CycleInfo getCycleInfo(const Building& building) const override;
private:
RecipeSelectionControl* m_schematicControl;
ShipLayoutPreview* m_layoutPreview;
QPushButton* m_configureButton;
};

View File

@@ -0,0 +1,211 @@
#include "SplitterContent.h"
#include <algorithm>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include <QLabel>
#include <QListWidget>
#include <QVBoxLayout>
#include "BeltSystem.h"
#include "BuildingTarget.h"
#include "ClearBeltControl.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "EventManager.h"
#include "FactoryQueries.h"
#include "GameConfig.h"
#include "ItemType.h"
#include "Rotation.h"
#include "SelectionNames.h"
#include "Simulation.h"
namespace
{
// Height cap on a filter list, so two of them plus the rest of the card still fit.
const int kFilterListHeightPx = 100;
QString getRotationLabel(Rotation rotation)
{
// Written as code points because the sources are read as ASCII by the compiler.
const QChar upArrow(0x2191); // U+2191 UPWARDS ARROW
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
const QChar downArrow(0x2193); // U+2193 DOWNWARDS ARROW
const QChar leftArrow(0x2190); // U+2190 LEFTWARDS ARROW
switch (rotation)
{
case Rotation::North: return QObject::tr("North (%1)").arg(upArrow);
case Rotation::East: return QObject::tr("East (%1)").arg(rightArrow);
case Rotation::South: return QObject::tr("South (%1)").arg(downArrow);
case Rotation::West: return QObject::tr("West (%1)").arg(leftArrow);
}
return QString();
}
// Every item type the economy knows, from both sides of every recipe.
std::vector<std::string> getAllItemIds(const RecipesConfig& recipes)
{
std::set<std::string> seen;
for (const RecipeDef& recipe : recipes.recipes)
{
for (const RecipeIngredient& ingredient : recipe.inputs)
{
seen.insert(ingredient.item);
}
for (const RecipeOutput& output : recipe.outputs)
{
seen.insert(output.item);
}
}
return std::vector<std::string>(seen.begin(), seen.end());
}
std::vector<ItemType> collectCheckedItems(const QListWidget* list)
{
std::vector<ItemType> filter;
for (int row = 0; row < list->count(); ++row)
{
const QListWidgetItem* item = list->item(row);
if (item->checkState() == Qt::Checked)
{
filter.push_back(ItemType{ item->text().toStdString() });
}
}
return filter;
}
} // namespace
SplitterContent::SplitterContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, asConstructionSite(context, request.buildings.front()),
parent)
, m_id(request.buildings.front())
, m_isSite(asConstructionSite(context, m_id).has_value())
, m_tile(0, 0)
{
m_filterALabel = new QLabel(this);
m_filterAList = new QListWidget(this);
m_filterBLabel = new QLabel(this);
m_filterBList = new QListWidget(this);
m_filterAList->setMaximumHeight(kFilterListHeightPx);
m_filterBList->setMaximumHeight(kFilterListHeightPx);
getConfigurationLayout()->addWidget(m_filterALabel);
getConfigurationLayout()->addWidget(m_filterAList);
getConfigurationLayout()->addWidget(m_filterBLabel);
getConfigurationLayout()->addWidget(m_filterBList);
getRuntimeLayout()->addWidget(
new ClearBeltControl(context, request.buildings, this));
// Populated once, here, rather than on every refresh: re-checking the boxes at 30 Hz
// would fight the player's clicks.
populateFilters();
connect(m_filterAList, &QListWidget::itemChanged,
this, [this]() { applyFilters(); });
connect(m_filterBList, &QListWidget::itemChanged,
this, [this]() { applyFilters(); });
}
void SplitterContent::refreshConfiguration()
{
const BuildingTarget target = resolveBuildingTarget(getContext(), m_id);
if (!target.isValid())
{
return;
}
setBuildingIdentity(target.type, getBuildingTypeName(target.type));
}
void SplitterContent::populateFilters()
{
const BuildingTarget target = resolveBuildingTarget(getContext(), m_id);
if (!target.isValid())
{
return;
}
// An operational splitter's outputs and filters live in the belt subsystem, keyed by
// tile; a site's are stored on the site itself (REQ-BLD-SITE-CONFIG).
std::optional<BeltSystem::SplitterInfo> info;
if (m_isSite)
{
info = getSiteSplitterInfo(getContext().sim->getFactoryState(),
*getContext().config, m_id);
}
else
{
m_tile = target.anchor;
info = getContext().sim->getBelts().getSplitterInfo(m_tile);
}
if (!info.has_value())
{
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
return;
}
const std::vector<std::string> itemIds = getAllItemIds(getContext().config->recipes);
auto fillList = [&](QListWidget* list, QLabel* label, const QString& directionLabel,
const std::vector<ItemType>& filter)
{
label->setText(tr("%1 filter (empty = all):").arg(directionLabel));
list->blockSignals(true);
list->clear();
for (const std::string& itemId : itemIds)
{
// Only implicitly unlocked item types are offered (REQ-LOCK-UI-SPLITTER).
if (!getContext().sim->isItemUnlocked(itemId)) { continue; }
QListWidgetItem* row =
new QListWidgetItem(QString::fromStdString(itemId), list);
const bool checked = !filter.empty()
&& std::find(filter.begin(), filter.end(), ItemType{ itemId })
!= filter.end();
row->setCheckState(checked ? Qt::Checked : Qt::Unchecked);
row->setFlags(row->flags() | Qt::ItemIsUserCheckable);
}
list->blockSignals(false);
label->show();
list->show();
};
fillList(m_filterAList, m_filterALabel, getRotationLabel(info->outputA),
info->filterA);
fillList(m_filterBList, m_filterBLabel, getRotationLabel(info->outputB),
info->filterB);
}
void SplitterContent::applyFilters()
{
if (m_isSite)
{
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = m_id;
command->filterA = collectCheckedItems(m_filterAList);
command->filterB = collectCheckedItems(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
return;
}
std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>();
command->tile = m_tile;
command->filterA = collectCheckedItems(m_filterAList);
command->filterB = collectCheckedItems(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <QPoint>
#include "BuildingId.h"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class QLabel;
class QListWidget;
// The card for a Splitter (REQ-UI-SELECTION-CONTENT): its two per-output item filters
// (REQ-BLD-SPLITTER) plus the clear action every belt-subsystem tile has
// (REQ-UI-BELT-CLEAR).
//
// The filters are configuration, so they are shown for a construction site too and set
// through the site's own command (REQ-BLD-SITE-CONFIG); the clear action is runtime and
// therefore is not, because a site's tile is not registered with the belt subsystem yet.
class SplitterContent : public SelectionContent
{
Q_OBJECT
public:
SplitterContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshConfiguration() override;
void refreshRuntime() override {}
private:
// Sends the checked items of both lists as the splitter's new filters. Routed to the
// site command or the live command depending on what the id names.
void applyFilters();
// Rebuilds both lists from the splitter's current outputs and filters. Only run when
// the lists are not already populated, so a per-tick refresh cannot uncheck a box
// the player is in the middle of clicking.
void populateFilters();
BuildingId m_id;
bool m_isSite;
QPoint m_tile;
QLabel* m_filterALabel;
QListWidget* m_filterAList;
QLabel* m_filterBLabel;
QListWidget* m_filterBList;
};

View File

@@ -0,0 +1,66 @@
#include "StationContent.h"
#include <QLabel>
#include <QVBoxLayout>
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "Simulation.h"
#include "WeaponComponent.h"
StationContent::StationContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, std::nullopt, parent)
, m_entity(request.actors.front())
{
m_statsLabel = new QLabel(this);
m_statsLabel->setWordWrap(true);
getRuntimeLayout()->addWidget(m_statsLabel);
EntityAdmin& admin = context.sim->getAdmin();
const bool isEnemy = admin.isValid(m_entity)
&& admin.hasAll<FactionComponent>(m_entity)
&& admin.get<FactionComponent>(m_entity).isEnemy;
setIdentity(QPixmap(), isEnemy ? tr("Enemy Defence Station")
: tr("Player Defence Station"));
}
void StationContent::refreshRuntime()
{
EntityAdmin& admin = getContext().sim->getAdmin();
if (!admin.isValid(m_entity) || !admin.hasAll<HealthComponent>(m_entity))
{
return;
}
const HealthComponent& health = admin.get<HealthComponent>(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 entt::entity station = m_entity;
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner,
const WeaponComponent& weapon)
{
if (owner.owner != station) { return; }
hasWeapon = true;
totalDps += weapon.damage * weapon.fireRateHz;
if (weapon.range_tiles > maxRange) { maxRange = weapon.range_tiles; }
});
QString text = tr("HP: %1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f));
if (hasWeapon)
{
text += tr("\nDPS: %1")
.arg(QString::number(static_cast<double>(totalDps), 'f', 1));
text += tr("\nRange: %1 tiles")
.arg(QString::number(static_cast<double>(maxRange), 'f', 1));
}
m_statsLabel->setText(text);
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class QLabel;
// 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
// the weapon modules mounted on it.
class StationContent : public SelectionContent
{
Q_OBJECT
public:
StationContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
entt::entity m_entity;
QLabel* m_statsLabel;
};

View File

@@ -0,0 +1,29 @@
#include "StorageContent.h"
#include "Building.h"
#include "BuildingTarget.h"
#include "SelectionNames.h"
StorageContent::StorageContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
}
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
{
// No recipe, no cycle: the card shows the output buffer alone, with no per-cycle
// denominators to show it against (REQ-BLD-SALVAGE-BAY).
return CycleInfo();
}

View File

@@ -0,0 +1,22 @@
#pragma once
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
// The card for a Salvage Bay (REQ-UI-SELECTION-CONTENT): its held scrap and nothing
// else. It has no recipe to configure and runs no production cycle
// (REQ-BLD-SALVAGE-BAY), so it is the buffered-building card with both of those left
// out -- its header status says whether it is holding scrap rather than whether it is
// producing (REQ-UI-SELECTION-STATUS).
class StorageContent : public BufferedBuildingContent
{
Q_OBJECT
public:
StorageContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshConfiguration() override;
CycleInfo getCycleInfo(const Building& building) const override;
};