diff --git a/docs/architecture.md b/docs/architecture.md index 762af23..99fa949 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -125,6 +125,7 @@ Three product targets plus tests: - `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets. - `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module. + - `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT). - `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`. - `tests/` — Catch2 tests. Links only against `lib`. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 929bc42..319bca3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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}" ) diff --git a/src/ui/BuildButtonBar.cpp b/src/ui/BuildButtonBar.cpp index be9e46d..0617dd6 100644 --- a/src/ui/BuildButtonBar.cpp +++ b/src/ui/BuildButtonBar.cpp @@ -2,9 +2,7 @@ #include -#include #include -#include #include #include #include @@ -15,12 +13,11 @@ #include #include #include -#include #include #include #include -#include +#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(kIconSize.width() * dpr), - static_cast(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 ".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()); diff --git a/src/ui/BuildButtonBar.h b/src/ui/BuildButtonBar.h index 7d00978..7ac55a4 100644 --- a/src/ui/BuildButtonBar.h +++ b/src/ui/BuildButtonBar.h @@ -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 ".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 m_types; std::vector m_buttons; std::map m_costs; diff --git a/src/ui/BuildingIconCache.cpp b/src/ui/BuildingIconCache.cpp new file mode 100644 index 0000000..5d983f0 --- /dev/null +++ b/src/ui/BuildingIconCache.cpp @@ -0,0 +1,120 @@ +#include "BuildingIconCache.h" + +#include +#include +#include +#include +#include + +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::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::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 key(cacheKey, sizePx); + const std::map, 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(sizePx * dpr), static_cast(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; +} diff --git a/src/ui/BuildingIconCache.h b/src/ui/BuildingIconCache.h new file mode 100644 index 0000000..0cc06c3 --- /dev/null +++ b/src/ui/BuildingIconCache.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +// 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 ".svg" chip files (typically + // "/../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 m_svgByName; + std::map m_greySvgByName; + std::map, QPixmap> m_pixmapCache; +}; diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 210c82d..180ba87 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -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 ) diff --git a/src/ui/FieldSelectionPanel.cpp b/src/ui/FieldSelectionPanel.cpp deleted file mode 100644 index 5bbba16..0000000 --- a/src/ui/FieldSelectionPanel.cpp +++ /dev/null @@ -1,403 +0,0 @@ -#include "FieldSelectionPanel.h" - -#include -#include -#include - -#include -#include -#include -#include - -#include "DebrisSystem.h" -#include "DisplayName.h" -#include "EntityAdmin.h" -#include "FactionComponent.h" -#include "GameConfig.h" -#include "HealthComponent.h" -#include "ModuleOwnerComponent.h" -#include "SelectedBehaviorComponent.h" -#include "ShipIdentityComponent.h" -#include "ShipStatsCalculator.h" -#include "ShipStatsPanel.h" -#include "Simulation.h" -#include "StationBodyComponent.h" -#include "ThreatCostCalculator.h" -#include "WeaponComponent.h" - - -FieldSelectionPanel::FieldSelectionPanel(Simulation* sim, - const GameConfig* config, - QWidget* parent) - : QWidget(parent) - , m_sim(sim) - , m_config(config) -{ - // Zero margins and the same spacing as the enclosing SelectionPanel layout, so - // nesting the field widgets in this panel leaves their geometry unchanged. - m_layout = new QVBoxLayout(this); - m_layout->setContentsMargins(0, 0, 0, 0); - m_layout->setSpacing(4); - m_layout->setAlignment(Qt::AlignTop); - - m_entityTitleLabel = new QLabel(this); - QFont titleFont = m_entityTitleLabel->font(); - titleFont.setBold(true); - m_entityTitleLabel->setFont(titleFont); - m_layout->addWidget(m_entityTitleLabel); - m_entityTitleLabel->hide(); - - m_entityStatsPanel = new ShipStatsPanel(config, this); - m_layout->addWidget(m_entityStatsPanel); - m_entityStatsPanel->hide(); - - m_stationStatsLabel = new QLabel(this); - m_stationStatsLabel->setWordWrap(true); - m_layout->addWidget(m_stationStatsLabel); - m_stationStatsLabel->hide(); - - m_entitySummaryLabel = new QLabel(this); - m_entitySummaryLabel->setWordWrap(true); - m_layout->addWidget(m_entitySummaryLabel); - m_entitySummaryLabel->hide(); - - m_scrapLabel = new QLabel(this); - m_layout->addWidget(m_scrapLabel); - m_scrapLabel->hide(); - - hide(); - - registerForEvents(); -} - -FieldSelectionPanel::~FieldSelectionPanel() -{ - unregisterForEvents(); -} - -void FieldSelectionPanel::setSelectedEntities(const std::vector& entities) -{ - m_selectedEntities = entities; - rebuild(); -} - -void FieldSelectionPanel::setSelectedDebris(const std::vector& debris) -{ - m_selectedDebris = debris; - rebuild(); -} - -void FieldSelectionPanel::clearSelection() -{ - m_selectedEntities.clear(); - m_selectedDebris.clear(); - rebuild(); -} - -bool FieldSelectionPanel::hasSelection() const -{ - return !m_selectedEntities.empty() || !m_selectedDebris.empty(); -} - -void FieldSelectionPanel::hideAllWidgets() -{ - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - m_entitySummaryLabel->hide(); - m_scrapLabel->hide(); -} - -void FieldSelectionPanel::rebuild() -{ - if (!hasSelection()) - { - // Nothing in the field category: take no space, leaving the panel to whatever - // the building category shows (REQ-UI-SELECTION-CATEGORIES). - hideAllWidgets(); - hide(); - return; - } - - show(); - - EntityAdmin& admin = m_sim->getAdmin(); - - // A full single-object stats panel is shown only for a lone field object: one actor - // with no debris, or one piece of debris with no actors. As soon as the selection holds - // more than one object (multiple actors, multiple debris, or actors plus debris), the - // panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION). - if (m_selectedEntities.size() == 1 && m_selectedDebris.empty()) - { - m_entitySummaryLabel->hide(); - m_scrapLabel->hide(); - const entt::entity entity = m_selectedEntities.front(); - if (admin.isValid(entity) && admin.hasAll(entity)) - { - buildEntityShip(entity); - } - else if (admin.isValid(entity) && admin.hasAll(entity)) - { - buildEntityStation(entity); - } - else - { - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - } - return; - } - - if (m_selectedEntities.empty() && m_selectedDebris.size() == 1) - { - // Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like - // the ship/station stats panels (REQ-UI-DEBRIS-PANEL). - m_entitySummaryLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - buildDebrisSingle(); - return; - } - - // More than one field object: a compact count summary. buildEntitySummary() appends the - // "Debris x N" and "Scrap x N" lines when debris is part of the selection. - m_entityTitleLabel->hide(); - m_entityStatsPanel->hide(); - m_stationStatsLabel->hide(); - m_scrapLabel->hide(); - buildEntitySummary(); -} - -void FieldSelectionPanel::refreshDisplay() -{ - if (!hasSelection()) { return; } - - // Keep the live values current: the single-actor stats panel, the single-debris stats - // panel (whose Scrap row shrinks as it is collected), or the count summary (whose Scrap - // line shrinks likewise) — matching the layout chosen by rebuild() - // (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL). - if (m_selectedEntities.size() == 1 && m_selectedDebris.empty()) - { - refreshEntityStats(); - } - else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1) - { - buildDebrisSingle(); - } - else - { - buildEntitySummary(); - } -} - -void FieldSelectionPanel::buildDebrisSingle() -{ - // "Debris" heading + a single "Scrap" stat row for the piece's remaining amount, - // mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL). - m_entityTitleLabel->setText(tr("Debris")); - m_entityTitleLabel->show(); - m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal())); - m_scrapLabel->show(); -} - -void FieldSelectionPanel::buildEntitySummary() -{ - EntityAdmin& admin = m_sim->getAdmin(); - - // Group actors by faction + kind + ship schematic, preserving first-seen order - // (REQ-UI-FIELD-MULTI-SELECTION). - std::vector keys; - std::map counts; - std::map labels; - - for (entt::entity entity : m_selectedEntities) - { - if (!admin.isValid(entity)) { continue; } - const bool isEnemy = admin.hasAll(entity) - && admin.get(entity).isEnemy; - - QString key; - QString label; - if (admin.hasAll(entity)) - { - const std::string& id = admin.get(entity).schematicId; - const QString name = QString::fromStdString(toDisplayName(id)); - key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:")) - + QString::fromStdString(id); - label = isEnemy ? tr("Enemy %1").arg(name) : name; - } - else if (admin.hasAll(entity)) - { - key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player"); - label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station"); - } - else - { - continue; - } - - if (counts.find(key) == counts.end()) - { - keys.push_back(key); - labels[key] = label; - } - counts[key] += 1; - } - - // One " x " line per group (matching the recipe tooltip and the building - // multi-selection). No total-count header, consistent with the building panel. When - // debris is part of the selection, a "Debris x " line followed by a - // "Scrap x " line are appended into the same label so the line spacing is - // uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL). - QStringList lines; - for (const QString& key : keys) - { - lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]); - } - if (!m_selectedDebris.empty()) - { - lines << tr("Debris x %1").arg(static_cast(m_selectedDebris.size())); - lines << scrapTotalText(); - } - m_entitySummaryLabel->setText(lines.join('\n')); - m_entitySummaryLabel->show(); -} - -void FieldSelectionPanel::buildEntityShip(entt::entity entity) -{ - EntityAdmin& admin = m_sim->getAdmin(); - const ShipIdentityComponent& identity = admin.get(entity); - const HealthComponent& health = admin.get(entity); - - m_entityTitleLabel->setText(tr("Ship: %1") - .arg(QString::fromStdString(identity.schematicId))); - m_entityTitleLabel->show(); - - const ShipStats stats = buildShipStatsFromEntity(admin, entity); - m_entityStatsPanel->refreshFromLive(stats, health.hp); - m_entityStatsPanel->setBehavior( - admin.get(entity).winner); - m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw); - - const ShipDef* schematicDef = - m_config->ships.findShipDef(identity.schematicId); - if (schematicDef) - { - const double threat = calculateShipThreatCost( - m_config->threatCosts, *m_config, schematicDef->id, - schematicDef->defaultModules); - m_entityStatsPanel->setThreatCost(threat); - } - - m_entityStatsPanel->show(); - - m_stationStatsLabel->hide(); -} - -void FieldSelectionPanel::buildEntityStation(entt::entity entity) -{ - EntityAdmin& admin = m_sim->getAdmin(); - const HealthComponent& health = admin.get(entity); - - const bool isEnemy = admin.hasAll(entity) - && admin.get(entity).isEnemy; - m_entityTitleLabel->setText(isEnemy - ? tr("Enemy Defence Station") - : tr("Player Defence Station")); - m_entityTitleLabel->show(); - - float totalDps = 0.0f; - float maxRange = 0.0f; - bool hasWeapons = false; - - admin.forEach( - [&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w) - { - if (owner.owner != entity) { return; } - hasWeapons = true; - totalDps += w.damage * w.fireRateHz; - if (w.range_tiles > maxRange) { maxRange = w.range_tiles; } - }); - - QString statsText = tr("HP: %1 / %2") - .arg(static_cast(health.hp + 0.5f)) - .arg(static_cast(health.maxHp + 0.5f)); - - if (hasWeapons) - { - statsText += tr("\nDPS: %1").arg(QString::number(static_cast(totalDps), 'f', 1)); - statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast(maxRange), 'f', 1)); - } - - m_stationStatsLabel->setText(statsText); - m_stationStatsLabel->show(); - - m_entityStatsPanel->hide(); -} - -void FieldSelectionPanel::refreshEntityStats() -{ - // Only the single-actor stats panel needs a live refresh; the multi-actor summary is - // static counts, and GameWorldView prunes dead/despawned actors and re-emits the - // selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here. - if (m_selectedEntities.size() != 1) { return; } - - EntityAdmin& admin = m_sim->getAdmin(); - const entt::entity entity = m_selectedEntities.front(); - - if (!admin.isValid(entity) || !admin.hasAll(entity)) { return; } - const HealthComponent& health = admin.get(entity); - if (health.hp <= 0.0f) { return; } - - if (admin.hasAll(entity)) - { - const ShipStats stats = buildShipStatsFromEntity(admin, entity); - m_entityStatsPanel->refreshFromLive(stats, health.hp); - m_entityStatsPanel->setBehavior( - admin.get(entity).winner); - } - else if (admin.hasAll(entity)) - { - buildEntityStation(entity); - } -} - -int FieldSelectionPanel::selectedDebrisScrapTotal() const -{ - // Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL). - int total = 0; - for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin())) - { - if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity) - != m_selectedDebris.end()) - { - total += info.amount; - } - } - return total; -} - -QString FieldSelectionPanel::scrapTotalText() const -{ - return tr("Scrap x %1").arg(selectedDebrisScrapTotal()); -} - -void FieldSelectionPanel::handleEvent(std::shared_ptr /*event*/) -{ - refreshDisplay(); -} - -void FieldSelectionPanel::handleEvent( - std::shared_ptr /*event*/) -{ - // Player commands are applied by a queued drain, not synchronously. When the game is - // paused no tick advances, so TickAdvancedEvent never fires; refresh here too. - refreshDisplay(); -} - -void FieldSelectionPanel::handleEvent(std::shared_ptr event) -{ - m_debugDraw = event->active; - m_entityStatsPanel->setDebugDrawEnabled(event->active); -} diff --git a/src/ui/FieldSelectionPanel.h b/src/ui/FieldSelectionPanel.h deleted file mode 100644 index 84ccb30..0000000 --- a/src/ui/FieldSelectionPanel.h +++ /dev/null @@ -1,91 +0,0 @@ -#pragma once - -#include - -#include -#include - -#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 -{ - 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& entities); - // Replaces the selected debris; the selected actors are left alone. - void setSelectedDebris(const std::vector& 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 event) override; - void handleEvent(std::shared_ptr event) override; - void handleEvent(std::shared_ptr 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 m_selectedEntities; - std::vector 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; -}; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 17fea10..a307d78 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -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( - QString::fromStdString(itemsIconDir)); + QDir::cleanPath(configDirPath + "/../icons/items")); + m_buildingIcons = std::make_unique( + 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). diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 192a2a1..c1bf965 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -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 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 m_buildingIcons; GameWorldView* m_gameWorldView; HeaderBar* m_headerBar; SelectionPanel* m_selectionPanel; diff --git a/src/ui/SelectionPanel.cpp b/src/ui/SelectionPanel.cpp index 246953a..aa791d2 100644 --- a/src/ui/SelectionPanel.cpp +++ b/src/ui/SelectionPanel.cpp @@ -1,39 +1,14 @@ #include "SelectionPanel.h" -#include "FactoryQueries.h" -#include -#include -#include -#include -#include - -#include -#include -#include #include #include -#include #include -#include "BeltSystem.h" -#include "Command.h" -#include "CommandRequestedEvent.h" -#include "EntitySelectionChangedEvent.h" -#include "EventManager.h" -#include "FieldSelectionPanel.h" -#include "TickAdvancedEvent.h" -#include "Building.h" -#include "BuildingSystem.h" -#include "BuildingType.h" -#include "ItemType.h" -#include "LayoutDialogRequestedEvent.h" -#include "ModulesConfig.h" -#include "PlayerCommandsAppliedEvent.h" -#include "RecipeSelectionDialog.h" -#include "RecipeSelectionRequestedEvent.h" -#include "Rotation.h" -#include "ShipLayoutPreview.h" +#include "BuildingIconCache.h" +#include "ItemIconCache.h" #include "Simulation.h" +#include "VisualsConfig.h" +#include "selection/SelectionContent.h" namespace { @@ -42,86 +17,31 @@ namespace // (REQ-UI-SELECTION-PANEL). const int kMarginPx = 8; -// Upper bound on the content width. The panel is content-sized, but several of its -// widgets have no natural width of their own -- the word-wrapped buffer and summary -// labels grow without limit, and a QListWidget asks for 256 px whatever it holds -- so -// the width is capped and the labels wrap at the cap. 320 px is the width the former -// side panel column had at the default window size. +// Upper bound on the card width. The panel is content-sized, but several of the cards' +// widgets have no natural width of their own -- the word-wrapped summary labels grow +// without limit, and a QListWidget asks for 256 px whatever it holds -- so the width is +// capped and the labels wrap at the cap. 320 px is the width the former side panel +// column had at the default window size. const int kMaxContentWidthPx = 320; -QString buildingTypeName(BuildingType type) -{ - if (type == BuildingType::Hq) - { - return QObject::tr("Player HQ"); - } - - const std::string id = buildingTypeId(type); - QString result; - bool nextUpper = true; - for (char c : id) - { - if (c == '_') - { - result += ' '; - nextUpper = true; - } - else if (nextUpper) - { - result += static_cast(std::toupper(static_cast(c))); - nextUpper = false; - } - else - { - result += c; - } - } - return result; -} - -bool isProductionBuilding(BuildingType type) -{ - return type == BuildingType::Miner - || type == BuildingType::Smelter - || type == BuildingType::Assembler - || type == BuildingType::ReprocessingPlant - || type == BuildingType::Shipyard; -} - -// Buildings that expose a player recipe/schematic selection control -// (REQ-UI-SELECT-BUTTON): Miner ore type, Assembler recipe, Shipyard schematic. -// The Smelter and Reprocessing Plant auto-process and offer no selection -// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). -bool hasRecipeSelection(BuildingType type) -{ - return type == BuildingType::Miner - || type == BuildingType::Assembler - || type == BuildingType::Shipyard; -} - -QString rotationLabel(Rotation r) -{ - switch (r) - { - case Rotation::North: return QObject::tr("North (↑)"); - case Rotation::East: return QObject::tr("East (→)"); - case Rotation::South: return QObject::tr("South (↓)"); - case Rotation::West: return QObject::tr("West (←)"); - } - return ""; -} +// Padding between the panel's border and the card inside it. +const int kCardMarginPx = 8; } // namespace -SelectionPanel::SelectionPanel(Simulation* sim, - const GameConfig* config, - QWidget* parent) +SelectionPanel::SelectionPanel(Simulation* sim, const GameConfig* config, + const VisualsConfig* visuals, ItemIconCache* itemIcons, + BuildingIconCache* buildingIcons, QWidget* parent) : QWidget(parent) - , m_sim(sim) - , m_config(config) - , m_splitterTile(0, 0) { + m_context.sim = sim; + m_context.config = config; + m_context.visuals = visuals; + m_context.itemIcons = itemIcons; + m_context.buildingIcons = buildingIcons; + m_context.debugDrawEnabled = &m_debugDrawEnabled; + // The panel floats over the rendered world rather than sitting in a column, so it // brings its own opaque background to stay legible over any world content // (REQ-UI-SELECTION-PANEL). Palette colors match the build button bar's chrome; like @@ -133,78 +53,32 @@ SelectionPanel::SelectionPanel(Simulation* sim, "SelectionPanel { background-color: palette(window);" " border: 1px solid palette(mid); border-radius: 4px; }")); - // Content taller than the band scrolls rather than overrunning it + // A card taller than the band scrolls rather than overrunning it // (REQ-UI-SELECTION-PANEL). The viewport is transparent so the panel's own rounded // chrome shows through, and horizontal scrolling is off because the width always // follows the content. - m_content = new QWidget(this); + m_body = new QWidget(this); m_scrollArea = new QScrollArea(this); m_scrollArea->setFrameShape(QFrame::NoFrame); m_scrollArea->setWidgetResizable(true); m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_scrollArea->viewport()->setAutoFillBackground(false); - m_content->setAutoFillBackground(false); - m_scrollArea->setWidget(m_content); + m_body->setAutoFillBackground(false); + m_scrollArea->setWidget(m_body); QVBoxLayout* outerLayout = new QVBoxLayout(this); outerLayout->setContentsMargins(0, 0, 0, 0); outerLayout->setSpacing(0); outerLayout->addWidget(m_scrollArea); - m_layout = new QVBoxLayout(m_content); - m_layout->setContentsMargins(8, 8, 8, 8); - m_layout->setSpacing(4); - m_layout->setAlignment(Qt::AlignTop); + m_bodyLayout = new QVBoxLayout(m_body); + m_bodyLayout->setContentsMargins(kCardMarginPx, kCardMarginPx, + kCardMarginPx, kCardMarginPx); + m_bodyLayout->setSpacing(0); + m_bodyLayout->setAlignment(Qt::AlignTop); - m_titleLabel = new QLabel(m_content); - m_recipeSelectButton = new QPushButton(m_content); - m_clearBeltBtn = new QPushButton(tr("Clear Items"), m_content); - m_filterALabel = new QLabel(m_content); - m_filterAList = new QListWidget(m_content); - m_filterBLabel = new QLabel(m_content); - m_filterBList = new QListWidget(m_content); - m_layoutPreview = new ShipLayoutPreview(m_content); - m_configureLayoutBtn = new QPushButton(tr("Configure Layout"), m_content); - m_buffersLabel = new QLabel(m_content); - m_buffersLabel->setWordWrap(true); - - m_filterAList->setMaximumHeight(100); - m_filterBList->setMaximumHeight(100); - - m_layout->addWidget(m_titleLabel); - m_layout->addWidget(m_recipeSelectButton); - m_layout->addWidget(m_layoutPreview); - m_layout->addWidget(m_configureLayoutBtn); - m_layout->addWidget(m_clearBeltBtn); - m_layout->addWidget(m_filterALabel); - m_layout->addWidget(m_filterAList); - m_layout->addWidget(m_filterBLabel); - m_layout->addWidget(m_filterBList); - m_layout->addWidget(m_buffersLabel); - - connect(m_recipeSelectButton, &QPushButton::clicked, - this, &SelectionPanel::onSelectRecipeClicked); - connect(m_clearBeltBtn, &QPushButton::clicked, - this, &SelectionPanel::onClearBelt); - connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() { - if (m_singleBuildingId.has_value()) - { - EventManager::getInstance()->sendEventImmediately( - std::make_shared(*m_singleBuildingId)); - } - }); - connect(m_filterAList, &QListWidget::itemChanged, - this, &SelectionPanel::onSplitterFilterChanged); - connect(m_filterBList, &QListWidget::itemChanged, - this, &SelectionPanel::onSplitterFilterChanged); - - // The field selection renders below the building content and hides itself while - // nothing field-side is selected, so it costs no space then. - m_fieldSelectionPanel = new FieldSelectionPanel(sim, config, m_content); - m_layout->addWidget(m_fieldSelectionPanel); - - buildEmpty(); + hide(); registerForEvents(); } @@ -220,41 +94,117 @@ void SelectionPanel::anchorTo(const QRect& bandRect) updateVisibility(); } -void SelectionPanel::onSelectionChanged(const std::vector& ids) +void SelectionPanel::handleEvent(std::shared_ptr event) { - m_selectedBuildingIds = ids; - if (!ids.empty()) + m_request.buildings = event->ids; + if (!m_request.buildings.empty()) { - // A building selection is exclusive: it supersedes any field selection — - // actors and scrap (REQ-UI-SELECTION-CATEGORIES). - m_fieldSelectionPanel->clearSelection(); + // A building selection is exclusive: it supersedes any field selection -- actors + // and scrap alike (REQ-UI-SELECTION-CATEGORIES). + m_request.actors.clear(); + m_request.debris.clear(); } - rebuild(); + rebuildContent(); } -void SelectionPanel::yieldToFieldSelection() +void SelectionPanel::handleEvent( + std::shared_ptr event) { - // The mirror image of onSelectionChanged(): a field selection — actors, debris, or - // both — supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). An empty - // field selection leaves the content alone: the building content, if any, keeps the - // panel; it only has to be re-checked for whether anything is left to show at all. - if (!m_fieldSelectionPanel->hasSelection()) + m_request.actors = event->entities; + if (!m_request.actors.empty() || !m_request.debris.empty()) + { + m_request.buildings.clear(); + } + rebuildContent(); +} + +void SelectionPanel::handleEvent( + std::shared_ptr event) +{ + // Debris is a field object: it supersedes any building selection but coexists with + // actors (REQ-UI-SELECTION-CATEGORIES). + m_request.debris = event->debris; + if (!m_request.actors.empty() || !m_request.debris.empty()) + { + m_request.buildings.clear(); + } + rebuildContent(); +} + +void SelectionPanel::handleEvent(std::shared_ptr /*event*/) +{ + refreshContent(); +} + +void SelectionPanel::handleEvent( + std::shared_ptr /*event*/) +{ + // Player commands (choosing a shipyard schematic, say) are applied by a queued + // drain, not synchronously. When the game is paused no tick advances, so + // TickAdvancedEvent never fires; refreshing here too is what makes the change show + // up without waiting for a tick or a re-selection. + refreshContent(); +} + +void SelectionPanel::handleEvent(std::shared_ptr event) +{ + m_debugDrawEnabled = event->active; +} + +void SelectionPanel::refreshContent() +{ + if (!m_content) { - updateVisibility(); return; } - buildEmpty(); + + // A card never changes its own shape, so when the selection now calls for a + // different one it is replaced rather than refreshed. Only a single selected + // building can reach that state without the selection itself changing -- its + // construction site finishes, or it is deconstructed under the panel. Everything + // else is re-published as a selection change, so re-deriving the key here would walk + // a large multi-selection every tick to learn nothing. + if (m_request.buildings.size() == 1 + && chooseContent(m_request, *m_context.sim) != m_contentKey) + { + rebuildContent(); + return; + } + + m_content->refresh(); + updateVisibility(); +} + +void SelectionPanel::rebuildContent() +{ + if (m_content) + { + // Retired rather than deleted: a rebuild can be reached from inside one of the + // card's own click handlers -- the recipe control opens a modal dialog and the + // choice comes back as a command -- and control has to be able to return into + // the widget that is going away. + m_content->hide(); + m_content->deleteLater(); + m_content = nullptr; + } + + m_contentKey = chooseContent(m_request, *m_context.sim); + m_content = createContent(m_contentKey, m_request, m_context, m_body); + if (m_content) + { + m_bodyLayout->addWidget(m_content); + m_content->refresh(); + } + updateVisibility(); } void SelectionPanel::updateVisibility() { - // Nothing selected in either category means no panel at all rather than an empty - // one (REQ-UI-EMPTY-SELECTION), leaving the whole game world view visible. Before - // the owner has anchored the panel there is nowhere to put it either, so it stays - // hidden until then. - const bool shouldShow = - (!m_selectedBuildingIds.empty() || m_fieldSelectionPanel->hasSelection()) - && !m_bandRect.isNull(); + // Nothing selected in either category means no panel at all rather than an empty one + // (REQ-UI-EMPTY-SELECTION), leaving the whole game world view visible. Before the + // owner has anchored the panel there is nowhere to put it either, so it stays hidden + // until then. + const bool shouldShow = (m_content != nullptr) && !m_bandRect.isNull(); setVisible(shouldShow); if (shouldShow) { @@ -262,21 +212,20 @@ void SelectionPanel::updateVisibility() } } -// Only ever reached through updateVisibility(), i.e. with a band to fit into. void SelectionPanel::refit() { // The layout drops hidden widgets from its size hint, but only once it has been - // re-run: the rebuild paths call this straight after hide()/show(), before Qt would - // get around to it on its own. - m_content->layout()->activate(); + // re-run: a card hides and shows its parts as it refreshes, before Qt would get + // around to it on its own. + m_body->layout()->activate(); const QRect band = m_bandRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx); - // The panel's border is drawn around the scroll area rather than around the - // content, so it is added to whatever the content asks for. Spelled out here - // instead of read back from contentsMargins() because the stylesheet box is what - // sets it, and asking the style for it before the first show is unreliable. + // The panel's border is drawn around the scroll area rather than around the card, so + // it is added to whatever the card asks for. Spelled out here instead of read back + // from contentsMargins() because the stylesheet box is what sets it, and asking the + // style for it before the first show is unreliable. const int borderPx = 1; const int maxWidthPx = qMin(kMaxContentWidthPx, band.width() - 2 * borderPx); const int maxHeightPx = band.height() - 2 * borderPx; @@ -285,21 +234,21 @@ void SelectionPanel::refit() return; } - int contentWidthPx = qMin(m_content->sizeHint().width(), maxWidthPx); + int contentWidthPx = qMin(m_body->sizeHint().width(), maxWidthPx); // Word-wrapped labels only know their height once the width is fixed; the layout // reports -1 when nothing in it wraps, in which case the plain hint is exact. - int contentHeightPx = m_content->heightForWidth(contentWidthPx); + int contentHeightPx = m_body->heightForWidth(contentWidthPx); if (contentHeightPx < 0) { - contentHeightPx = m_content->sizeHint().height(); + contentHeightPx = m_body->sizeHint().height(); } if (contentHeightPx > maxHeightPx) { - // Content taller than the band is capped there and scrolls - // (REQ-UI-SELECTION-PANEL). The scroll bar is laid out beside the content, so - // the panel widens by its width to keep the content as wide as the height was - // computed for. + // A card taller than the band is capped there and scrolls + // (REQ-UI-SELECTION-PANEL). The scroll bar is laid out beside the card, so the + // panel widens by its width to keep the card as wide as the height was computed + // for. contentHeightPx = maxHeightPx; contentWidthPx = qMin( contentWidthPx + m_scrollArea->verticalScrollBar()->sizeHint().width(), @@ -316,694 +265,3 @@ void SelectionPanel::refit() band.top() + (band.height() - panelHeightPx) / 2, panelWidthPx, panelHeightPx); } - -void SelectionPanel::rebuild() -{ - if (m_selectedBuildingIds.empty()) - { - buildEmpty(); - } - else if (m_selectedBuildingIds.size() == 1) - { - buildSingle(m_selectedBuildingIds[0]); - } - else - { - buildMulti(m_selectedBuildingIds); - } -} - -void SelectionPanel::hideAllWidgets() -{ - m_titleLabel->hide(); - m_recipeSelectButton->hide(); - m_layoutPreview->hide(); - m_configureLayoutBtn->hide(); - m_clearBeltBtn->hide(); - m_filterALabel->hide(); - m_filterAList->hide(); - m_filterBLabel->hide(); - m_filterBList->hide(); - m_buffersLabel->hide(); -} - -void SelectionPanel::buildEmpty() -{ - // Shows nothing for the building category — either because nothing is selected or - // because the field category has taken the panel over. - m_singleBuildingId = std::nullopt; - // Also reached when the selected building has gone away under the panel (it was - // deconstructed, or its site finished building): dropping the ids keeps them from - // outliving the content and holding the panel on screen (REQ-UI-EMPTY-SELECTION). - m_selectedBuildingIds.clear(); - hideAllWidgets(); - updateVisibility(); -} - -void SelectionPanel::buildSingle(BuildingId id) -{ - m_singleBuildingId = id; - hideAllWidgets(); - - const Building* b = findBuilding(m_sim->getFactoryState(), id); - const ConstructionSite* s = b ? nullptr : findSite(m_sim->getFactoryState(), id); - if (!b && !s) - { - buildEmpty(); - return; - } - m_singleIsSite = (s != nullptr); - - // A construction site exposes the same configuration as the operational - // building it will become (REQ-BLD-SITE-CONFIG). The only difference is - // that its buffer/production rows are replaced by a construction-progress - // line, since a site has no buffers and runs no production cycle. - const BuildingType type = b ? b->type : s->type; - const std::string& recipeId = b ? b->recipeId : s->recipeId; - const std::optional& shipLayout = - b ? b->shipLayout : s->shipLayout; - const QPoint anchor = b ? b->anchor : s->anchor; - - m_titleLabel->setText(m_singleIsSite - ? tr("(Building) %1").arg(buildingTypeName(type)) - : buildingTypeName(type)); - m_titleLabel->show(); - m_buffersLabel->show(); - - if (hasRecipeSelection(type)) - { - const std::vector options = - buildRecipeSelectionOptions(type, *m_sim, *m_config); - - const RecipeSelectionOption* current = nullptr; - for (const RecipeSelectionOption& option : options) - { - if (option.id == recipeId) - { - current = &option; - break; - } - } - - if (current && !current->id.empty()) - { - m_recipeSelectButton->setText(current->caption); - m_recipeSelectButton->setToolTip(current->tooltip); - } - else - { - const QString placeholder = (type == BuildingType::Shipyard) - ? tr("Select schematic") - : tr("Select recipe"); - m_recipeSelectButton->setText(placeholder); - m_recipeSelectButton->setToolTip(QString()); - } - m_recipeSelectButton->show(); - - updateShipyardLayoutWidgets(type, recipeId, shipLayout); - } - else - { - m_recipeSelectButton->hide(); - updateShipyardLayoutWidgets(type, recipeId, shipLayout); - } - - // Belt "Clear" removes items from a live belt tile; a construction site has - // none and is not registered with BeltSystem yet, so hide it for sites. - if (isBeltSubsystemType(type) && !m_singleIsSite) - { - m_clearBeltBtn->show(); - } - else - { - m_clearBeltBtn->hide(); - } - - if (type == BuildingType::Splitter) - { - std::optional info; - if (m_singleIsSite) - { - info = getSiteSplitterInfo(m_sim->getFactoryState(), m_sim->getConfig(), id); - } - else - { - m_splitterTile = anchor; - info = m_sim->getBelts().getSplitterInfo(m_splitterTile); - } - buildSplitterFilters(info); - } - else - { - m_filterALabel->hide(); - m_filterAList->hide(); - m_filterBLabel->hide(); - m_filterBList->hide(); - } - - if (m_singleIsSite) - { - refreshSiteProgress(s); - } - else - { - refreshBuffers(b); - } -} - -void SelectionPanel::refreshSiteProgress(const ConstructionSite* s) -{ - QString progress; - if (s->completesAt == 0) - { - progress = tr("Queued"); - } - else - { - const BuildingDef* def = nullptr; - for (const BuildingDef& d : m_config->buildings.buildings) - { - if (d.type == s->type) { def = &d; break; } - } - if (def && def->constructionTimeSeconds > 0) - { - const Tick duration = secondsToTicks(def->constructionTimeSeconds); - const Tick elapsed = m_sim->getCurrentTick() - (s->completesAt - duration); - const int pct = static_cast( - std::max(Tick(0), std::min(duration, elapsed)) * 100 / duration); - progress = tr("%1% complete").arg(pct); - } - else - { - progress = tr("Building..."); - } - } - m_buffersLabel->setText(progress); - - // The progress line changes width as it counts up, and the panel is sized to its - // content, so every refresh re-fits it (REQ-UI-SELECTION-PANEL). - updateVisibility(); -} - -void SelectionPanel::refreshBuffers(const Building* b) -{ - const RecipeDef* recipe = findRecipe(b); - const ShipDef* shipDef = (b->type == BuildingType::Shipyard) - ? findShipDef(b->recipeId) - : nullptr; - - // Auto-recipe buildings (Smelter, Reprocessing Plant) have no selected - // recipe; while a cycle runs, resolve the recipe actually in production so - // the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS). - if (!recipe && isAutoRecipeBuildingType(b->type) && b->production.has_value()) - { - recipe = m_config->recipes.findRecipeDef(b->production->recipeId, b->type); - } - - QString bufText; - - if (!b->inputBuffer.counts.empty()) - { - bufText += tr("Input: "); - for (const std::pair& entry : b->inputBuffer.counts) - { - int perCycle = 0; - if (recipe) - { - for (const RecipeIngredient& ing : recipe->inputs) - { - if (ing.item == entry.first.id) { perCycle = ing.amount; break; } - } - } - else if (shipDef) - { - for (const RecipeIngredient& mat : shipDef->schematic.materials) - { - if (mat.item == entry.first.id) { perCycle = mat.amount; break; } - } - if (b->shipLayout.has_value()) - { - for (const PlacedModule& pm : b->shipLayout->placedModules) - { - const ModuleDef* modDef = - m_config->modules.findModuleDef(pm.moduleId); - if (!modDef) { continue; } - for (const RecipeIngredient& ing : modDef->materials) - { - if (ing.item == entry.first.id) - { - perCycle += ing.amount; - } - } - } - } - } - bufText += QString::fromStdString(entry.first.id) - + ": " + QString::number(entry.second); - if (perCycle > 0) - { - bufText += "/" + QString::number(perCycle); - } - bufText += " "; - } - bufText += "\n"; - } - - // Count output-side items: buffered plus still-emerging on the output belts. - // An emerging item still belongs to the output buffer (REQ-MAT-OUTPUT-EMERGE), - // so it must be included here or it would vanish from the panel while animating. - std::map outCounts; - for (const Item& item : b->outputBuffer.items) - { - outCounts[item.type.id]++; - } - for (const std::vector& lane : b->emergingItems) - { - for (const BeltItemSlot& slot : lane) - { - outCounts[slot.item.type.id]++; - } - } - - if (recipe && !recipe->outputs.empty()) - { - bufText += tr("Output: "); - for (const RecipeOutput& out : recipe->outputs) - { - const std::map::const_iterator it = - outCounts.find(out.item); - const int count = (it != outCounts.end()) ? it->second : 0; - bufText += QString::fromStdString(out.item) - + ": " + QString::number(count) - + "/" + QString::number(out.amount) + " "; - } - } - else if (!outCounts.empty()) - { - bufText += tr("Output: "); - for (const std::pair& entry : outCounts) - { - bufText += QString::fromStdString(entry.first) - + ": " + QString::number(entry.second) + " "; - } - } - - if (isProductionBuilding(b->type) - && (recipe || shipDef || isAutoRecipeBuildingType(b->type))) - { - if (recipe || shipDef) - { - double durationSeconds = recipe - ? recipe->durationSeconds - : shipDef->schematic.productionTimeSeconds; - - if (shipDef && b->shipLayout.has_value()) - { - for (const PlacedModule& pm : b->shipLayout->placedModules) - { - const ModuleDef* modDef = - m_config->modules.findModuleDef(pm.moduleId); - if (modDef) - { - durationSeconds += modDef->productionTimeSeconds; - } - } - } - - bufText += tr("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1); - - if (b->production.has_value()) - { - const Tick cycleTicks = secondsToTicks(durationSeconds); - const Tick completesAt = b->production->completesAt; - const Tick currentTick = m_sim->getCurrentTick(); - const Tick elapsed = currentTick - (completesAt - cycleTicks); - const int pct = static_cast( - std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks); - bufText += tr("Progress: %1%\n").arg(pct); - } - else - { - bufText += tr("Progress: idle\n"); - } - } - else - { - // Auto-recipe building with no active cycle: no single recipe to - // show a cycle time for. - bufText += tr("Progress: idle\n"); - } - } - - m_buffersLabel->setText(bufText); - - // The recipe/schematic is applied via a queued command that only drains on a - // later frame, so the per-tick refresh must own the shipyard preview and the - // Configure Layout button's visibility; otherwise they stay hidden until the - // building is re-selected (which re-runs buildSingle). - updateShipyardLayoutWidgets(b->type, b->recipeId, b->shipLayout); - - // Buffer counts and the progress line change width as they run, and the panel is - // sized to its content, so every refresh re-fits it (REQ-UI-SELECTION-PANEL). - updateVisibility(); -} - -void SelectionPanel::updateShipyardLayoutWidgets( - BuildingType type, - const std::string& recipeId, - const std::optional& shipLayout) -{ - // The preview and Configure button are shipyard-only controls; hide them - // entirely for other building types. - if (type != BuildingType::Shipyard) - { - m_layoutPreview->hide(); - m_configureLayoutBtn->hide(); - return; - } - - const ShipDef* shipDef = findShipDef(recipeId); - const bool hasSchematic = shipDef && !shipDef->layout.empty(); - - // Always show the preview and Configure button for a shipyard; they are only - // enabled once a schematic is selected (REQ-MOD-UI-PREVIEW). - if (hasSchematic) - { - ShipLayoutConfig layout; - if (shipLayout.has_value()) - { - layout = *shipLayout; - } - m_layoutPreview->setShipAndLayout( - shipDef->layout, layout, &m_config->modules); - } - else - { - m_layoutPreview->showPlaceholder(); - } - - m_layoutPreview->setEnabled(hasSchematic); - m_configureLayoutBtn->setEnabled(hasSchematic); - m_layoutPreview->show(); - m_configureLayoutBtn->show(); -} - -const RecipeDef* SelectionPanel::findRecipe(const Building* b) const -{ - if (b->recipeId.empty()) { return nullptr; } - return m_config->recipes.findRecipeDef(b->recipeId, b->type); -} - -const ShipDef* SelectionPanel::findShipDef(const std::string& id) const -{ - if (id.empty()) { return nullptr; } - return m_config->ships.findShipDef(id); -} - -void SelectionPanel::handleEvent(std::shared_ptr /*event*/) -{ - refreshSelectionDisplay(RefreshReason::PeriodicTick); -} - -void SelectionPanel::handleEvent( - std::shared_ptr /*event*/) -{ - // Player commands (e.g. choosing a shipyard schematic) are applied by a - // queued drain, not synchronously. When the game is paused no tick advances, - // so TickAdvancedEvent never fires; refresh here too, otherwise the panel - // would not reflect the change until the next tick or a re-selection. - refreshSelectionDisplay(RefreshReason::CommandApplied); -} - -void SelectionPanel::refreshSelectionDisplay(RefreshReason reason) -{ - // Only a single selected building has live content to refresh. While the field - // category owns the panel there is none: yieldToFieldSelection() has cleared it, so - // this returns immediately and the field panel refreshes itself off the same events. - // Its content changes size as it does (a ship's HP, a debris pile's scrap), so the - // panel is re-fitted to it before returning. - if (!m_singleBuildingId.has_value()) - { - updateVisibility(); - return; - } - const Building* b = findBuilding(m_sim->getFactoryState(), *m_singleBuildingId); - if (b) - { - if (m_titleLabel->text().startsWith(tr("(Building) "))) - { - rebuild(); - } - else - { - refreshBuffers(b); - } - return; - } - const ConstructionSite* s = findSite(m_sim->getFactoryState(), *m_singleBuildingId); - if (s) - { - // A periodic tick only advances construction progress, so update just the - // progress label. Rebuilding every tick would hide/re-show all widgets and - // cancel any in-progress click on the recipe button. An applied command - // may have changed the site's recipe/layout, so rebuild in that case. - if (reason == RefreshReason::CommandApplied) - { - rebuild(); - } - else - { - refreshSiteProgress(s); - } - return; - } - buildEmpty(); -} - -void SelectionPanel::buildMulti(const std::vector& ids) -{ - m_singleBuildingId = std::nullopt; - m_recipeSelectButton->hide(); - m_clearBeltBtn->hide(); - m_filterALabel->hide(); - m_filterAList->hide(); - m_filterBLabel->hide(); - m_filterBList->hide(); - m_buffersLabel->hide(); - // Per-building detail is not shown for a multi-selection (REQ-UI-MULTI-SELECTION), - // so a shipyard's preview must not survive from a previous single selection — it - // would both mislead and pad the content-sized panel. - m_layoutPreview->hide(); - m_configureLayoutBtn->hide(); - - std::map counts; - for (BuildingId id : ids) - { - const Building* b = findBuilding(m_sim->getFactoryState(), id); - if (b) - { - counts[b->type]++; - continue; - } - const ConstructionSite* s = findSite(m_sim->getFactoryState(), id); - if (s) - { - counts[s->type]++; - } - } - - bool hasBelt = false; - int totalCost = 0; - QString text; - for (const std::pair& entry : counts) - { - text += buildingTypeName(entry.first) + " x " - + QString::number(entry.second) + "\n"; - if (isBeltSubsystemType(entry.first)) - { - hasBelt = true; - } - // Total placement cost counts only player-placeable buildings; the HQ - // and defence stations are excluded (REQ-UI-MULTI-SELECTION). - const BuildingDef* def = m_config->buildings.findBuildingDef(entry.first); - if (def && def->playerPlaceable) - { - totalCost += def->cost * entry.second; - } - } - text += tr("Total: %1 Building Blocks").arg(totalCost); - m_titleLabel->setText(text.trimmed()); - m_titleLabel->show(); - - if (hasBelt) - { - m_clearBeltBtn->show(); - } - - updateVisibility(); -} - -void SelectionPanel::onSelectRecipeClicked() -{ - if (!m_singleBuildingId.has_value()) - { - return; - } - // The emit is synchronous: MainWindow pauses the game, runs the modal - // selection dialog, and restores the speed before this returns. The chosen - // recipe/schematic is only *enqueued* as a command, though, and drains on a - // later frame -- so this rebuild() still sees the old recipe. The per-tick - // refreshBuffers() path picks up the new schematic (and shows the layout - // preview + Configure Layout button) once the command has been applied. - EventManager::getInstance()->sendEventImmediately( - std::make_shared(*m_singleBuildingId)); - rebuild(); -} - -void SelectionPanel::buildSplitterFilters( - const std::optional& info) -{ - if (!info.has_value()) - { - m_filterALabel->hide(); - m_filterAList->hide(); - m_filterBLabel->hide(); - m_filterBList->hide(); - return; - } - - const std::vector items = getAllItemIds(); - - auto populateList = [&](QListWidget* list, QLabel* label, - const QString& dirLabel, - const std::vector& filter) - { - label->setText(tr("%1 filter (empty = all):").arg(dirLabel)); - list->blockSignals(true); - list->clear(); - for (const std::string& itemId : items) - { - if (!m_sim->isItemUnlocked(itemId)) { continue; } - QListWidgetItem* row = new QListWidgetItem( - QString::fromStdString(itemId), list); - const bool checked = filter.empty() - ? false - : 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(); - }; - - populateList(m_filterAList, m_filterALabel, - rotationLabel(info->outputA), info->filterA); - populateList(m_filterBList, m_filterBLabel, - rotationLabel(info->outputB), info->filterB); -} - -void SelectionPanel::onSplitterFilterChanged() -{ - if (!m_singleBuildingId.has_value()) - { - return; - } - - auto collectFilter = [](QListWidget* list) -> std::vector - { - std::vector filter; - for (int i = 0; i < list->count(); ++i) - { - const QListWidgetItem* row = list->item(i); - if (row->checkState() == Qt::Checked) - { - filter.push_back(ItemType{row->text().toStdString()}); - } - } - return filter; - }; - - if (m_singleIsSite) - { - std::shared_ptr command = - std::make_shared(); - command->id = *m_singleBuildingId; - command->filterA = collectFilter(m_filterAList); - command->filterB = collectFilter(m_filterBList); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(command)); - } - else - { - std::shared_ptr command = - std::make_shared(); - command->tile = m_splitterTile; - command->filterA = collectFilter(m_filterAList); - command->filterB = collectFilter(m_filterBList); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(command)); - } -} - -std::vector SelectionPanel::getAllItemIds() const -{ - std::set seen; - for (const RecipeDef& recipe : m_config->recipes.recipes) - { - for (const RecipeIngredient& ing : recipe.inputs) - { - seen.insert(ing.item); - } - for (const RecipeOutput& out : recipe.outputs) - { - seen.insert(out.item); - } - } - return std::vector(seen.begin(), seen.end()); -} - -void SelectionPanel::onClearBelt() -{ - std::vector tiles; - for (BuildingId id : m_selectedBuildingIds) - { - const Building* b = findBuilding(m_sim->getFactoryState(), id); - if (b && isBeltSubsystemType(b->type)) - { - for (const QPoint& cell : b->bodyCells) - { - tiles.push_back(cell); - } - } - } - if (!tiles.empty()) - { - std::shared_ptr command = - std::make_shared(); - command->tiles = std::move(tiles); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(command)); - } -} - -void SelectionPanel::handleEvent(std::shared_ptr event) -{ - m_fieldSelectionPanel->setSelectedEntities(event->entities); - yieldToFieldSelection(); -} - -void SelectionPanel::handleEvent(std::shared_ptr event) -{ - onSelectionChanged(event->ids); -} - -void SelectionPanel::handleEvent( - std::shared_ptr event) -{ - // Debris is a field object: it supersedes any building selection but coexists - // with actors (REQ-UI-SELECTION-CATEGORIES). - m_fieldSelectionPanel->setSelectedDebris(event->debris); - yieldToFieldSelection(); -} diff --git a/src/ui/SelectionPanel.h b/src/ui/SelectionPanel.h index d7a7cb5..e6a2b35 100644 --- a/src/ui/SelectionPanel.h +++ b/src/ui/SelectionPanel.h @@ -1,48 +1,34 @@ #pragma once -#include -#include -#include - -#include #include #include -#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 event) override; + void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; - void handleEvent(std::shared_ptr event) override; - void handleEvent(std::shared_ptr event) override; - void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr 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& 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& ids); - void refreshBuffers(const Building* b); - void refreshSiteProgress(const ConstructionSite* s); - void updateShipyardLayoutWidgets(BuildingType type, - const std::string& recipeId, - const std::optional& shipLayout); - void buildSplitterFilters(const std::optional& info); - const RecipeDef* findRecipe(const Building* b) const; - const ShipDef* findShipDef(const std::string& id) const; - std::vector getAllItemIds() const; - Simulation* m_sim; - const GameConfig* m_config; - std::vector 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 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; }; diff --git a/src/ui/selection/AutoProductionContent.cpp b/src/ui/selection/AutoProductionContent.cpp new file mode 100644 index 0000000..4cff9dd --- /dev/null +++ b/src/ui/selection/AutoProductionContent.cpp @@ -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; +} diff --git a/src/ui/selection/AutoProductionContent.h b/src/ui/selection/AutoProductionContent.h new file mode 100644 index 0000000..aeac346 --- /dev/null +++ b/src/ui/selection/AutoProductionContent.h @@ -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; +}; diff --git a/src/ui/selection/BeltContent.cpp b/src/ui/selection/BeltContent.cpp new file mode 100644 index 0000000..40f75dc --- /dev/null +++ b/src/ui/selection/BeltContent.cpp @@ -0,0 +1,38 @@ +#include "BeltContent.h" + +#include + +#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(m_ids.size())); + } +} diff --git a/src/ui/selection/BeltContent.h b/src/ui/selection/BeltContent.h new file mode 100644 index 0000000..ba46b26 --- /dev/null +++ b/src/ui/selection/BeltContent.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#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 m_ids; +}; diff --git a/src/ui/selection/BufferSection.cpp b/src/ui/selection/BufferSection.cpp new file mode 100644 index 0000000..c2bbead --- /dev/null +++ b/src/ui/selection/BufferSection.cpp @@ -0,0 +1,95 @@ +#include "BufferSection.h" + +#include +#include + +#include "Building.h" + +namespace +{ + +// One ": [/]" entry. +QString formatEntry(const std::string& itemId, int count, int perCycle) +{ + QString text = QString::fromStdString(itemId) + ": " + QString::number(count); + if (perCycle > 0) + { + text += "/" + QString::number(perCycle); + } + return text + " "; +} + +int findPerCycle(const std::map& perCycle, const std::string& itemId) +{ + const std::map::const_iterator it = perCycle.find(itemId); + 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& perCycleInputs, + const std::map& perCycleOutputs) +{ + QString text; + + if (!building.inputBuffer.counts.empty()) + { + text += tr("Input: "); + for (const std::pair& 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 outputCounts; + for (const Item& item : building.outputBuffer.items) + { + outputCounts[item.type.id]++; + } + for (const std::vector& 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& entry : perCycleOutputs) + { + outputCounts.emplace(entry.first, 0); + } + + if (!outputCounts.empty()) + { + text += tr("Output: "); + for (const std::pair& entry : outputCounts) + { + text += formatEntry(entry.first, entry.second, + findPerCycle(perCycleOutputs, entry.first)); + } + } + + m_label->setText(text.trimmed()); + setVisible(!text.trimmed().isEmpty()); +} diff --git a/src/ui/selection/BufferSection.h b/src/ui/selection/BufferSection.h new file mode 100644 index 0000000..637e148 --- /dev/null +++ b/src/ui/selection/BufferSection.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include + +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& perCycleInputs, + const std::map& perCycleOutputs); + +private: + QLabel* m_label; +}; diff --git a/src/ui/selection/BufferedBuildingContent.cpp b/src/ui/selection/BufferedBuildingContent.cpp new file mode 100644 index 0000000..fe78122 --- /dev/null +++ b/src/ui/selection/BufferedBuildingContent.cpp @@ -0,0 +1,39 @@ +#include "BufferedBuildingContent.h" + +#include + +#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()); +} diff --git a/src/ui/selection/BufferedBuildingContent.h b/src/ui/selection/BufferedBuildingContent.h new file mode 100644 index 0000000..cc22450 --- /dev/null +++ b/src/ui/selection/BufferedBuildingContent.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +#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 perCycleInputs; + std::map 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; +}; diff --git a/src/ui/selection/BuildingTarget.cpp b/src/ui/selection/BuildingTarget.cpp new file mode 100644 index 0000000..d9b6042 --- /dev/null +++ b/src/ui/selection/BuildingTarget.cpp @@ -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 asConstructionSite(const SelectionContext& context, + BuildingId id) +{ + if (findSite(context.sim->getFactoryState(), id) != nullptr) + { + return id; + } + return std::nullopt; +} diff --git a/src/ui/selection/BuildingTarget.h b/src/ui/selection/BuildingTarget.h new file mode 100644 index 0000000..0d5d391 --- /dev/null +++ b/src/ui/selection/BuildingTarget.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include + +#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 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 asConstructionSite(const SelectionContext& context, + BuildingId id); diff --git a/src/ui/selection/CMakeLists.txt b/src/ui/selection/CMakeLists.txt new file mode 100644 index 0000000..1946450 --- /dev/null +++ b/src/ui/selection/CMakeLists.txt @@ -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 +) diff --git a/src/ui/selection/ClearBeltControl.cpp b/src/ui/selection/ClearBeltControl.cpp new file mode 100644 index 0000000..7dcd4ee --- /dev/null +++ b/src/ui/selection/ClearBeltControl.cpp @@ -0,0 +1,54 @@ +#include "ClearBeltControl.h" + +#include +#include +#include + +#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& 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 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 command = + std::make_shared(); + command->tiles = std::move(tiles); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); +} diff --git a/src/ui/selection/ClearBeltControl.h b/src/ui/selection/ClearBeltControl.h new file mode 100644 index 0000000..5a43b30 --- /dev/null +++ b/src/ui/selection/ClearBeltControl.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include + +#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& ids, QWidget* parent = nullptr); + +private: + void clearSelectedTiles() const; + + SelectionContext m_context; + std::vector m_ids; +}; diff --git a/src/ui/selection/DebrisContent.cpp b/src/ui/selection/DebrisContent.cpp new file mode 100644 index 0000000..9384075 --- /dev/null +++ b/src/ui/selection/DebrisContent.cpp @@ -0,0 +1,30 @@ +#include "DebrisContent.h" + +#include +#include + +#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(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))); +} diff --git a/src/ui/selection/DebrisContent.h b/src/ui/selection/DebrisContent.h new file mode 100644 index 0000000..6f1bd1a --- /dev/null +++ b/src/ui/selection/DebrisContent.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#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 m_debris; + QLabel* m_scrapLabel; +}; diff --git a/src/ui/selection/DebrisScrap.cpp b/src/ui/selection/DebrisScrap.cpp new file mode 100644 index 0000000..b6da72b --- /dev/null +++ b/src/ui/selection/DebrisScrap.cpp @@ -0,0 +1,18 @@ +#include "DebrisScrap.h" + +#include + +#include "DebrisSystem.h" + +int sumDebrisScrap(const EntityAdmin& admin, const std::vector& 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; +} diff --git a/src/ui/selection/DebrisScrap.h b/src/ui/selection/DebrisScrap.h new file mode 100644 index 0000000..05ddc04 --- /dev/null +++ b/src/ui/selection/DebrisScrap.h @@ -0,0 +1,13 @@ +#pragma once + +#include + +#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& debris); diff --git a/src/ui/selection/FieldMultiContent.cpp b/src/ui/selection/FieldMultiContent.cpp new file mode 100644 index 0000000..7a83df2 --- /dev/null +++ b/src/ui/selection/FieldMultiContent.cpp @@ -0,0 +1,91 @@ +#include "FieldMultiContent.h" + +#include +#include + +#include +#include +#include + +#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(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 keys; + std::map counts; + std::map labels; + + for (entt::entity actor : m_actors) + { + if (!admin.isValid(actor)) { continue; } + const bool isEnemy = admin.hasAll(actor) + && admin.get(actor).isEnemy; + + QString key; + QString label; + if (admin.hasAll(actor)) + { + const std::string& id = admin.get(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(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(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')); +} diff --git a/src/ui/selection/FieldMultiContent.h b/src/ui/selection/FieldMultiContent.h new file mode 100644 index 0000000..3f2f688 --- /dev/null +++ b/src/ui/selection/FieldMultiContent.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#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 m_actors; + std::vector m_debris; + QLabel* m_summaryLabel; +}; diff --git a/src/ui/selection/HqContent.cpp b/src/ui/selection/HqContent.cpp new file mode 100644 index 0000000..9b7516a --- /dev/null +++ b/src/ui/selection/HqContent.cpp @@ -0,0 +1,42 @@ +#include "HqContent.h" + +#include +#include + +#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( + [this](entt::entity /*entity*/, const HqProxyComponent& /*proxy*/, + const HealthComponent& health) + { + m_hpLabel->setText(tr("HP: %1 / %2") + .arg(static_cast(health.hp + 0.5f)) + .arg(static_cast(health.maxHp + 0.5f))); + }); +} diff --git a/src/ui/selection/HqContent.h b/src/ui/selection/HqContent.h new file mode 100644 index 0000000..212ba15 --- /dev/null +++ b/src/ui/selection/HqContent.h @@ -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; +}; diff --git a/src/ui/selection/MultiBuildingContent.cpp b/src/ui/selection/MultiBuildingContent.cpp new file mode 100644 index 0000000..2707939 --- /dev/null +++ b/src/ui/selection/MultiBuildingContent.cpp @@ -0,0 +1,90 @@ +#include "MultiBuildingContent.h" + +#include + +#include +#include +#include + +#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(m_ids.size()))); + + buildSummary(); +} + +void MultiBuildingContent::buildSummary() +{ + std::map 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& 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)); +} diff --git a/src/ui/selection/MultiBuildingContent.h b/src/ui/selection/MultiBuildingContent.h new file mode 100644 index 0000000..9303964 --- /dev/null +++ b/src/ui/selection/MultiBuildingContent.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#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 m_ids; + QLabel* m_countsLabel; + QLabel* m_totalCostLabel; +}; diff --git a/src/ui/selection/ProductionSection.cpp b/src/ui/selection/ProductionSection.cpp new file mode 100644 index 0000000..ea66b33 --- /dev/null +++ b/src/ui/selection/ProductionSection.cpp @@ -0,0 +1,53 @@ +#include "ProductionSection.h" + +#include + +#include +#include + +#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( + 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(); +} diff --git a/src/ui/selection/ProductionSection.h b/src/ui/selection/ProductionSection.h new file mode 100644 index 0000000..1c4a0fb --- /dev/null +++ b/src/ui/selection/ProductionSection.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#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; +}; diff --git a/src/ui/selection/RecipeProductionContent.cpp b/src/ui/selection/RecipeProductionContent.cpp new file mode 100644 index 0000000..53049e0 --- /dev/null +++ b/src/ui/selection/RecipeProductionContent.cpp @@ -0,0 +1,59 @@ +#include "RecipeProductionContent.h" + +#include + +#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; +} diff --git a/src/ui/selection/RecipeProductionContent.h b/src/ui/selection/RecipeProductionContent.h new file mode 100644 index 0000000..87ea57d --- /dev/null +++ b/src/ui/selection/RecipeProductionContent.h @@ -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; +}; diff --git a/src/ui/selection/RecipeSelectionControl.cpp b/src/ui/selection/RecipeSelectionControl.cpp new file mode 100644 index 0000000..df43d62 --- /dev/null +++ b/src/ui/selection/RecipeSelectionControl.cpp @@ -0,0 +1,57 @@ +#include "RecipeSelectionControl.h" + +#include + +#include +#include + +#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(m_id)); + }); +} + +void RecipeSelectionControl::setRecipeId(const std::string& recipeId) +{ + const std::vector 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()); +} diff --git a/src/ui/selection/RecipeSelectionControl.h b/src/ui/selection/RecipeSelectionControl.h new file mode 100644 index 0000000..76a83e6 --- /dev/null +++ b/src/ui/selection/RecipeSelectionControl.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include + +#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; +}; diff --git a/src/ui/selection/SelectionContent.cpp b/src/ui/selection/SelectionContent.cpp new file mode 100644 index 0000000..8c082a1 --- /dev/null +++ b/src/ui/selection/SelectionContent.cpp @@ -0,0 +1,254 @@ +#include "SelectionContent.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#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(kStatusDotSizePx * dpr), + static_cast(kStatusDotSizePx * dpr)); + pixmap.setDevicePixelRatio(dpr); + pixmap.fill(Qt::transparent); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(outline); + painter.setBrush(fill); + // Inset by half the pen width so the outline stays inside the pixmap. + painter.drawEllipse(QRectF(0.5, 0.5, kStatusDotSizePx - 1.0, kStatusDotSizePx - 1.0)); + return pixmap; +} + +} // namespace + + +SelectionContent::SelectionContent(const SelectionContext& context, + std::optional 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(m_configurationGroup->layout()); +} + +QVBoxLayout* SelectionContent::getRuntimeLayout() +{ + return static_cast(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 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( + std::max(Tick(0), std::min(duration, elapsed)) * 100 / duration); + progress = tr("Construction: %1%").arg(percent); + } + else + { + progress = tr("Building..."); + } + } + m_constructionLabel->setText(progress); +} diff --git a/src/ui/selection/SelectionContent.h b/src/ui/selection/SelectionContent.h new file mode 100644 index 0000000..a7b6bbb --- /dev/null +++ b/src/ui/selection/SelectionContent.h @@ -0,0 +1,101 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#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 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", 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 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; +}; diff --git a/src/ui/selection/SelectionContentFactory.cpp b/src/ui/selection/SelectionContentFactory.cpp new file mode 100644 index 0000000..1ad2f43 --- /dev/null +++ b/src/ui/selection/SelectionContentFactory.cpp @@ -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(actor)) + { + return { SelectionContentKind::Ship, false }; + } + if (admin.isValid(actor) && admin.hasAll(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; +} diff --git a/src/ui/selection/SelectionContentFactory.h b/src/ui/selection/SelectionContentFactory.h new file mode 100644 index 0000000..7c2d074 --- /dev/null +++ b/src/ui/selection/SelectionContentFactory.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +#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 buildings; + std::vector actors; + std::vector 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); diff --git a/src/ui/selection/SelectionContext.h b/src/ui/selection/SelectionContext.h new file mode 100644 index 0000000..4414016 --- /dev/null +++ b/src/ui/selection/SelectionContext.h @@ -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; +}; diff --git a/src/ui/selection/SelectionNames.cpp b/src/ui/selection/SelectionNames.cpp new file mode 100644 index 0000000..ec30ff4 --- /dev/null +++ b/src/ui/selection/SelectionNames.cpp @@ -0,0 +1,16 @@ +#include "SelectionNames.h" + +#include + +#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))); +} diff --git a/src/ui/selection/SelectionNames.h b/src/ui/selection/SelectionNames.h new file mode 100644 index 0000000..12009f4 --- /dev/null +++ b/src/ui/selection/SelectionNames.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +#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); diff --git a/src/ui/selection/ShipContent.cpp b/src/ui/selection/ShipContent.cpp new file mode 100644 index 0000000..4319803 --- /dev/null +++ b/src/ui/selection/ShipContent.cpp @@ -0,0 +1,62 @@ +#include "ShipContent.h" + +#include + +#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(m_entity)) + { + setIdentity(QPixmap(), tr("Ship: %1").arg(QString::fromStdString( + admin.get(m_entity).schematicId))); + } +} + +void ShipContent::refreshRuntime() +{ + EntityAdmin& admin = getContext().sim->getAdmin(); + if (!admin.isValid(m_entity) || !admin.hasAll(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(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(m_entity).winner); + m_statsPanel->setDebugDrawEnabled(*getContext().debugDrawEnabled); + + const ShipIdentityComponent& identity = admin.get(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)); + } +} diff --git a/src/ui/selection/ShipContent.h b/src/ui/selection/ShipContent.h new file mode 100644 index 0000000..8f37728 --- /dev/null +++ b/src/ui/selection/ShipContent.h @@ -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; +}; diff --git a/src/ui/selection/ShipyardContent.cpp b/src/ui/selection/ShipyardContent.cpp new file mode 100644 index 0000000..83d0cfa --- /dev/null +++ b/src/ui/selection/ShipyardContent.cpp @@ -0,0 +1,103 @@ +#include "ShipyardContent.h" + +#include +#include + +#include "Building.h" +#include "BuildingTarget.h" +#include "EventManager.h" +#include "GameConfig.h" +#include "LayoutDialogRequestedEvent.h" +#include "ProductionRules.h" +#include "RecipeSelectionControl.h" +#include "SelectionNames.h" +#include "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(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; +} diff --git a/src/ui/selection/ShipyardContent.h b/src/ui/selection/ShipyardContent.h new file mode 100644 index 0000000..5a07f1a --- /dev/null +++ b/src/ui/selection/ShipyardContent.h @@ -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; +}; diff --git a/src/ui/selection/SplitterContent.cpp b/src/ui/selection/SplitterContent.cpp new file mode 100644 index 0000000..5bbf967 --- /dev/null +++ b/src/ui/selection/SplitterContent.cpp @@ -0,0 +1,211 @@ +#include "SplitterContent.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +#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 getAllItemIds(const RecipesConfig& recipes) +{ + std::set 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(seen.begin(), seen.end()); +} + +std::vector collectCheckedItems(const QListWidget* list) +{ + std::vector 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 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 itemIds = getAllItemIds(getContext().config->recipes); + + auto fillList = [&](QListWidget* list, QLabel* label, const QString& directionLabel, + const std::vector& 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 command = + std::make_shared(); + command->id = m_id; + command->filterA = collectCheckedItems(m_filterAList); + command->filterB = collectCheckedItems(m_filterBList); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); + return; + } + + std::shared_ptr command = + std::make_shared(); + command->tile = m_tile; + command->filterA = collectCheckedItems(m_filterAList); + command->filterB = collectCheckedItems(m_filterBList); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); +} diff --git a/src/ui/selection/SplitterContent.h b/src/ui/selection/SplitterContent.h new file mode 100644 index 0000000..5f85d93 --- /dev/null +++ b/src/ui/selection/SplitterContent.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +#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; +}; diff --git a/src/ui/selection/StationContent.cpp b/src/ui/selection/StationContent.cpp new file mode 100644 index 0000000..708ff6c --- /dev/null +++ b/src/ui/selection/StationContent.cpp @@ -0,0 +1,66 @@ +#include "StationContent.h" + +#include +#include + +#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(m_entity) + && admin.get(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(m_entity)) + { + return; + } + const HealthComponent& health = admin.get(m_entity); + + // A station's weapons are child module entities pointing back at it, so its combined + // damage and range are summed over those rather than read off the station itself. + float totalDps = 0.0f; + float maxRange = 0.0f; + bool hasWeapon = false; + const entt::entity station = m_entity; + admin.forEach( + [&](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(health.hp + 0.5f)) + .arg(static_cast(health.maxHp + 0.5f)); + if (hasWeapon) + { + text += tr("\nDPS: %1") + .arg(QString::number(static_cast(totalDps), 'f', 1)); + text += tr("\nRange: %1 tiles") + .arg(QString::number(static_cast(maxRange), 'f', 1)); + } + m_statsLabel->setText(text); +} diff --git a/src/ui/selection/StationContent.h b/src/ui/selection/StationContent.h new file mode 100644 index 0000000..c7af74a --- /dev/null +++ b/src/ui/selection/StationContent.h @@ -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; +}; diff --git a/src/ui/selection/StorageContent.cpp b/src/ui/selection/StorageContent.cpp new file mode 100644 index 0000000..e0fe9f5 --- /dev/null +++ b/src/ui/selection/StorageContent.cpp @@ -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(); +} diff --git a/src/ui/selection/StorageContent.h b/src/ui/selection/StorageContent.h new file mode 100644 index 0000000..d4ac026 --- /dev/null +++ b/src/ui/selection/StorageContent.h @@ -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; +};