diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 2b9ea80..65da064 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -421,6 +421,14 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path) const toml::array& outputs = requireArray(mt["outputs"], file, elemPath + ".outputs"); def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs"); + // Optional icon item id (REQ-UI-RECIPE-ICON); defaults to the first output + // in the UI when unset. Not validated against known items here — a missing + // icon is not an error (REQ-UI-ITEM-ICON). + if (mt.contains("icon")) + { + def.icon = requireString(mt["icon"], file, elemPath + ".icon"); + } + cfg.recipes.push_back(std::move(def)); } diff --git a/src/lib/config/RecipesConfig.h b/src/lib/config/RecipesConfig.h index c75e9ab..de484ea 100644 --- a/src/lib/config/RecipesConfig.h +++ b/src/lib/config/RecipesConfig.h @@ -32,6 +32,10 @@ struct RecipeDef std::vector inputs; std::vector outputs; double durationSeconds; + // Optional id of the item whose icon represents this recipe in the recipe- + // selection dialog (REQ-UI-RECIPE-ICON). When unset, the first output item is + // used. A missing icon file for that item is not an error (REQ-UI-ITEM-ICON). + std::optional icon; // Assembler only. When true, this recipe is available from game start // regardless of the implicit item graph — used for base recipes that no // schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT. diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index bfdf4bf..ab3b875 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -390,6 +390,39 @@ duration_seconds = 1.0 std::runtime_error); } +TEST_CASE("Optional recipe icon field parses; absent leaves it unset", "[config]") +{ + TempConfigDir dir; + writeFile(dir.path() / "recipes.toml", R"( +[[recipe]] +id = "with_icon" +building = "assembler" +inputs = [{item = "iron_ingot", amount = 1}] +outputs = [{item = "steel_plate", amount = 1}] +duration_seconds = 1.0 +icon = "hardened_steel" + +[[recipe]] +id = "without_icon" +building = "assembler" +inputs = [{item = "iron_ingot", amount = 1}] +outputs = [{item = "copper_wire", amount = 1}] +duration_seconds = 1.0 +)"); + + const RecipesConfig cfg = + ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()); + + const RecipeDef& withIcon = cfg.recipes.at(0); + REQUIRE(withIcon.id == "with_icon"); + REQUIRE(withIcon.icon.has_value()); + REQUIRE(*withIcon.icon == "hardened_steel"); + + const RecipeDef& withoutIcon = cfg.recipes.at(1); + REQUIRE(withoutIcon.id == "without_icon"); + REQUIRE_FALSE(withoutIcon.icon.has_value()); +} + // --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------ namespace diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 33a09c8..4f521d8 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -15,6 +15,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h PARENT_SCOPE ) @@ -34,5 +35,6 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp PARENT_SCOPE ) diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 83bde00..f5b57bc 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -49,6 +49,7 @@ #include "GameOverEvent.h" #include "HealthComponent.h" #include "HqProxyComponent.h" +#include "ItemIconCache.h" #include "PositionComponent.h" #include "RepairBehavior.h" #include "SalvageScrapBehavior.h" @@ -222,6 +223,11 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, loadBuildingIcons(configDir); + // Item icons live beside the config dir, mirroring the building icons + // (REQ-UI-ITEM-ICON, REQ-UI-WORLD-ICON). + m_itemIcons = std::make_unique(QDir::cleanPath( + QString::fromStdString(configDir) + "/../icons/items")); + m_renderTimer = new QTimer(this); m_renderTimer->setInterval(16); connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame); @@ -1597,23 +1603,15 @@ void GameWorldView::drawPortItems(QPainter& painter) } } - // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile filled square + outline. + // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile item icon, or the + // colored-square fallback (REQ-UI-ITEM-ICON), via the same draw path. const std::function drawItem = [&](const ItemType& type, QPointF worldPos) { - const std::map::const_iterator it = - m_visuals->items.find(type.id); - if (it == m_visuals->items.end()) { return; } - const QPointF center = worldToWidget( QVector2D(static_cast(worldPos.x()), static_cast(worldPos.y()))); - const QRectF itemRect(center.x() - halfPx, center.y() - halfPx, - halfPx * 2, halfPx * 2); - painter.fillRect(itemRect, it->second.fill); - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(itemRect); + drawWorldItem(painter, type.id, center, halfPx); }; painter.save(); @@ -1623,6 +1621,33 @@ void GameWorldView::drawPortItems(QPainter& painter) painter.restore(); } +void GameWorldView::drawWorldItem(QPainter& painter, const std::string& itemId, + QPointF center, float halfPx) +{ + const QRectF itemRect(center.x() - halfPx, center.y() - halfPx, + halfPx * 2, halfPx * 2); + + // Prefer the item's icon (REQ-UI-ITEM-ICON); it is rasterized once at the current + // half-tile pixel size and cached, so this is a plain pixmap blit per frame. + if (m_itemIcons && m_itemIcons->hasIcon(itemId)) + { + int sizePx = qRound(static_cast(halfPx * 2.0f)); + if (sizePx < 1) { sizePx = 1; } + painter.drawPixmap(itemRect, m_itemIcons->getPixmap(itemId, sizePx), + QRectF(0, 0, sizePx, sizePx)); + return; + } + + // Fallback: the colored square from visuals.toml (REQ-GW-TILE-SIZE). + const std::map::const_iterator it = + m_visuals->items.find(itemId); + if (it == m_visuals->items.end()) { return; } + painter.fillRect(itemRect, it->second.fill); + painter.setPen(QPen(it->second.outline, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(itemRect); +} + void GameWorldView::drawBeltItems(QPainter& painter) { const float halfPx = getTilePx() * 0.5f * 0.5f; @@ -1630,19 +1655,10 @@ void GameWorldView::drawBeltItems(QPainter& painter) m_sim->getBelts().forEachVisualItem(vr, [&](const VisualItem& vi) { - const std::map::const_iterator it = - m_visuals->items.find(vi.type.id); - if (it == m_visuals->items.end()) { return; } - const QPointF center = worldToWidget( QVector2D(static_cast(vi.worldPos.x()), static_cast(vi.worldPos.y()))); - const QRectF rect(center.x() - halfPx, center.y() - halfPx, - halfPx * 2, halfPx * 2); - painter.fillRect(rect, it->second.fill); - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(rect); + drawWorldItem(painter, vi.type.id, center, halfPx); }); } diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 84d2765..805b681 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,7 @@ struct Command; struct ParsedReplay; +class ItemIconCache; class ReplayPlayer; class Simulation; class QPainter; @@ -128,6 +130,12 @@ private: void drawCopyConfigFeedback(QPainter& painter); void drawStations(QPainter& painter); void drawBeltItems(QPainter& painter); + // Draws a single item centered at widget-space `center`, spanning `halfPx` in + // each direction (a half-tile). Uses the item's icon when one exists + // (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from + // visuals.toml. Shared by drawBeltItems and drawPortItems. + void drawWorldItem(QPainter& painter, const std::string& itemId, + QPointF center, float halfPx); void drawDebris(QPainter& painter); void drawShips(QPainter& painter); void drawHpBar(QPainter& painter, qreal left, qreal top, qreal width, @@ -296,6 +304,10 @@ private: }; std::map m_buildingIcons; + // Per-item icon cache (REQ-UI-ITEM-ICON), loaded from /../icons/items. + // Shared draw path for belt and port items; pixmaps are cached per target size. + std::unique_ptr m_itemIcons; + // Funnels all player input into the single Simulation::apply chokepoint. CommandManager m_commandManager; // A Reset command was enqueued; reset the view after the next drain applies it. diff --git a/src/ui/ItemIconCache.cpp b/src/ui/ItemIconCache.cpp new file mode 100644 index 0000000..9981e22 --- /dev/null +++ b/src/ui/ItemIconCache.cpp @@ -0,0 +1,64 @@ +#include "ItemIconCache.h" + +#include +#include +#include + +ItemIconCache::ItemIconCache(const QString& iconDir) + : m_iconDir(iconDir) +{ +} + +const QByteArray& ItemIconCache::getSvg(const std::string& itemId) +{ + const std::map::const_iterator cached = + m_svgById.find(itemId); + if (cached != m_svgById.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-ITEM-ICON). + QByteArray svg; + QFile file(m_iconDir + "/" + QString::fromStdString(itemId) + ".svg"); + if (file.open(QIODevice::ReadOnly)) + { + svg = file.readAll(); + } + return m_svgById.emplace(itemId, std::move(svg)).first->second; +} + +bool ItemIconCache::hasIcon(const std::string& itemId) +{ + return !getSvg(itemId).isEmpty(); +} + +QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx) +{ + if (sizePx <= 0) + { + return QPixmap(); + } + + const std::pair key(itemId, sizePx); + const std::map, QPixmap>::const_iterator cached = + m_pixmapCache.find(key); + if (cached != m_pixmapCache.end()) + { + return cached->second; + } + + const QByteArray& svg = getSvg(itemId); + QPixmap pixmap; + if (!svg.isEmpty()) + { + QSvgRenderer renderer(svg); + pixmap = QPixmap(sizePx, sizePx); + 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/ItemIconCache.h b/src/ui/ItemIconCache.h new file mode 100644 index 0000000..9eaf904 --- /dev/null +++ b/src/ui/ItemIconCache.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +// Rasterizes and caches per-item icon SVGs (REQ-UI-ITEM-ICON). Item icons are +// self-contained, full-color SVGs loaded from a directory, one file per item type +// named after the item's id (e.g. "iron_ore.svg"). Shared by the recipe-selection +// dialog (REQ-UI-RECIPE-ICON) and the game world's belt/port item rendering 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 colored square in the world, a name caption in the dialog). +class ItemIconCache +{ +public: + // iconDir is the directory holding the ".svg" icon files + // (typically "/../icons/items"). + explicit ItemIconCache(const QString& iconDir); + + // True if an icon SVG file exists for the given item id. Loads the file's bytes + // on first query and remembers the result (including absence) so repeated calls + // are cheap. + bool hasIcon(const std::string& itemId); + + // Returns the item's icon rasterized to a transparent sizePx*sizePx pixmap, + // cached per (item id, size) so it is rendered once and reused across frames and + // only re-rasterized when the target size changes (REQ-UI-ITEM-ICON). Returns a + // null pixmap if the item has no icon file (callers should gate on hasIcon()). + QPixmap getPixmap(const std::string& itemId, int sizePx); + +private: + // Returns the raw SVG bytes for an item id, 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& itemId); + + QString m_iconDir; + std::map m_svgById; + std::map, QPixmap> m_pixmapCache; +}; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 14fe5f5..b846322 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -323,7 +323,11 @@ void MainWindow::handleEvent(std::shared_ptr command = std::make_shared(); diff --git a/src/ui/RecipeSelectionDialog.cpp b/src/ui/RecipeSelectionDialog.cpp index 4039639..d3fd19b 100644 --- a/src/ui/RecipeSelectionDialog.cpp +++ b/src/ui/RecipeSelectionDialog.cpp @@ -3,7 +3,10 @@ #include #include +#include +#include #include +#include #include #include @@ -11,6 +14,7 @@ #include "BuildingType.h" #include "DisplayName.h" #include "GameConfig.h" +#include "ItemIconCache.h" #include "RecipesConfig.h" #include "RecipeTooltip.h" #include "ShipsConfig.h" @@ -83,23 +87,38 @@ std::vector buildRecipeSelectionOptions( { continue; } + // Icon shown on the option button (REQ-UI-RECIPE-ICON): the recipe's + // configured icon item, else its first output item. + const std::string iconItemId = recipe.icon + ? *recipe.icon + : (recipe.outputs.empty() ? std::string() + : recipe.outputs.front().item); options.push_back({recipe.id, QString::fromStdString(toDisplayName(recipe.id)), - buildRecipeTooltip(recipe)}); + buildRecipeTooltip(recipe), + iconItemId}); } } return options; } +namespace +{ + // On-screen size of a recipe option button's item icon (REQ-UI-RECIPE-ICON). + const QSize kOptionIconSize(32, 32); +} + RecipeSelectionDialog::RecipeSelectionDialog( const std::vector& options, - const QString& title, QWidget* parent) + const QString& title, const QString& itemIconDir, QWidget* parent) : QDialog(parent) { setWindowTitle(title); setModal(true); + ItemIconCache iconCache(itemIconDir); + QVBoxLayout* mainLayout = new QVBoxLayout(this); QGridLayout* grid = new QGridLayout(); mainLayout->addLayout(grid); @@ -109,7 +128,19 @@ RecipeSelectionDialog::RecipeSelectionDialog( { const RecipeSelectionOption& option = options[static_cast(i)]; - QPushButton* button = new QPushButton(option.caption, this); + QPushButton* button = new QPushButton(this); + // Icon-only when the produced item has an icon (REQ-UI-RECIPE-ICON); otherwise + // fall back to the caption. The name stays reachable via the tooltip. + if (!option.iconItemId.empty() && iconCache.hasIcon(option.iconItemId)) + { + button->setIcon(QIcon(iconCache.getPixmap( + option.iconItemId, kOptionIconSize.width()))); + button->setIconSize(kOptionIconSize); + } + else + { + button->setText(option.caption); + } if (!option.tooltip.isEmpty()) { button->setToolTip(option.tooltip); diff --git a/src/ui/RecipeSelectionDialog.h b/src/ui/RecipeSelectionDialog.h index 82b1e8f..6ad9914 100644 --- a/src/ui/RecipeSelectionDialog.h +++ b/src/ui/RecipeSelectionDialog.h @@ -20,6 +20,10 @@ struct RecipeSelectionOption std::string id; QString caption; QString tooltip; + // Id of the item whose icon is shown on the option button (REQ-UI-RECIPE-ICON); + // empty for the "(None)" option and for Shipyard schematics, which keep their + // caption. When set but no icon file exists, the button falls back to the caption. + std::string iconItemId; }; // Builds the lock-aware list of selectable options for a production building @@ -39,8 +43,12 @@ class RecipeSelectionDialog : public QDialog Q_OBJECT public: + // itemIconDir is the directory holding per-item icon SVGs (REQ-UI-ITEM-ICON); + // used to render recipe options icon-only (REQ-UI-RECIPE-ICON). Options whose + // item has no icon file fall back to their caption text. RecipeSelectionDialog(const std::vector& options, - const QString& title, QWidget* parent = nullptr); + const QString& title, const QString& itemIconDir, + QWidget* parent = nullptr); std::optional getChosenId() const;