Render produced-item icons in recipe dialog and game world

Implements REQ-UI-ITEM-ICON and REQ-UI-RECIPE-ICON. Adds an optional
recipes.toml `icon` field (an item id; defaults to the recipe's first
output). New ItemIconCache loads per-item SVGs from icons/items and
caches pixmaps per target pixel size, shared by:
- the Miner/Assembler recipe-selection dialog, which now shows icon-only
  option buttons (name-caption fallback when no icon file exists), and
- GameWorldView belt/port item rendering via a shared drawWorldItem
  helper (colored-square fallback preserved).

Shipyard schematic dialog is unchanged. No icon art is shipped; every
path falls back cleanly when an item has no SVG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
This commit is contained in:
2026-07-23 14:43:51 +02:00
parent 1cc4175071
commit 6a317a52b1
11 changed files with 253 additions and 26 deletions

View File

@@ -421,6 +421,14 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
const toml::array& outputs = requireArray(mt["outputs"], file, elemPath + ".outputs"); const toml::array& outputs = requireArray(mt["outputs"], file, elemPath + ".outputs");
def.outputs = parseRecipeOutputs(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)); cfg.recipes.push_back(std::move(def));
} }

View File

@@ -32,6 +32,10 @@ struct RecipeDef
std::vector<RecipeIngredient> inputs; std::vector<RecipeIngredient> inputs;
std::vector<RecipeOutput> outputs; std::vector<RecipeOutput> outputs;
double durationSeconds; 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<std::string> icon;
// Assembler only. When true, this recipe is available from game start // Assembler only. When true, this recipe is available from game start
// regardless of the implicit item graph — used for base recipes that no // regardless of the implicit item graph — used for base recipes that no
// schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT. // schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT.

View File

@@ -390,6 +390,39 @@ duration_seconds = 1.0
std::runtime_error); 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) ------------------------------------ // --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------
namespace namespace

View File

@@ -15,6 +15,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -34,5 +35,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -49,6 +49,7 @@
#include "GameOverEvent.h" #include "GameOverEvent.h"
#include "HealthComponent.h" #include "HealthComponent.h"
#include "HqProxyComponent.h" #include "HqProxyComponent.h"
#include "ItemIconCache.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "RepairBehavior.h" #include "RepairBehavior.h"
#include "SalvageScrapBehavior.h" #include "SalvageScrapBehavior.h"
@@ -222,6 +223,11 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
loadBuildingIcons(configDir); 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<ItemIconCache>(QDir::cleanPath(
QString::fromStdString(configDir) + "/../icons/items"));
m_renderTimer = new QTimer(this); m_renderTimer = new QTimer(this);
m_renderTimer->setInterval(16); m_renderTimer->setInterval(16);
connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame); 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<void(const ItemType&, QPointF)> drawItem = const std::function<void(const ItemType&, QPointF)> drawItem =
[&](const ItemType& type, QPointF worldPos) [&](const ItemType& type, QPointF worldPos)
{ {
const std::map<std::string, ItemVisuals>::const_iterator it =
m_visuals->items.find(type.id);
if (it == m_visuals->items.end()) { return; }
const QPointF center = worldToWidget( const QPointF center = worldToWidget(
QVector2D(static_cast<float>(worldPos.x()), QVector2D(static_cast<float>(worldPos.x()),
static_cast<float>(worldPos.y()))); static_cast<float>(worldPos.y())));
const QRectF itemRect(center.x() - halfPx, center.y() - halfPx, drawWorldItem(painter, type.id, center, 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);
}; };
painter.save(); painter.save();
@@ -1623,6 +1621,33 @@ void GameWorldView::drawPortItems(QPainter& painter)
painter.restore(); 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<qreal>(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<std::string, ItemVisuals>::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) void GameWorldView::drawBeltItems(QPainter& painter)
{ {
const float halfPx = getTilePx() * 0.5f * 0.5f; 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) m_sim->getBelts().forEachVisualItem(vr, [&](const VisualItem& vi)
{ {
const std::map<std::string, ItemVisuals>::const_iterator it =
m_visuals->items.find(vi.type.id);
if (it == m_visuals->items.end()) { return; }
const QPointF center = worldToWidget( const QPointF center = worldToWidget(
QVector2D(static_cast<float>(vi.worldPos.x()), QVector2D(static_cast<float>(vi.worldPos.x()),
static_cast<float>(vi.worldPos.y()))); static_cast<float>(vi.worldPos.y())));
const QRectF rect(center.x() - halfPx, center.y() - halfPx, drawWorldItem(painter, vi.type.id, center, 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);
}); });
} }

View File

@@ -13,6 +13,7 @@
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QOpenGLWidget> #include <QOpenGLWidget>
#include <QPoint> #include <QPoint>
#include <QPointF>
#include <QRectF> #include <QRectF>
#include <QTimer> #include <QTimer>
#include <QVector2D> #include <QVector2D>
@@ -51,6 +52,7 @@
struct Command; struct Command;
struct ParsedReplay; struct ParsedReplay;
class ItemIconCache;
class ReplayPlayer; class ReplayPlayer;
class Simulation; class Simulation;
class QPainter; class QPainter;
@@ -128,6 +130,12 @@ private:
void drawCopyConfigFeedback(QPainter& painter); void drawCopyConfigFeedback(QPainter& painter);
void drawStations(QPainter& painter); void drawStations(QPainter& painter);
void drawBeltItems(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 drawDebris(QPainter& painter);
void drawShips(QPainter& painter); void drawShips(QPainter& painter);
void drawHpBar(QPainter& painter, qreal left, qreal top, qreal width, void drawHpBar(QPainter& painter, qreal left, qreal top, qreal width,
@@ -296,6 +304,10 @@ private:
}; };
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons; std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
// Per-item icon cache (REQ-UI-ITEM-ICON), loaded from <configDir>/../icons/items.
// Shared draw path for belt and port items; pixmaps are cached per target size.
std::unique_ptr<ItemIconCache> m_itemIcons;
// Funnels all player input into the single Simulation::apply chokepoint. // Funnels all player input into the single Simulation::apply chokepoint.
CommandManager m_commandManager; CommandManager m_commandManager;
// A Reset command was enqueued; reset the view after the next drain applies it. // A Reset command was enqueued; reset the view after the next drain applies it.

64
src/ui/ItemIconCache.cpp Normal file
View File

@@ -0,0 +1,64 @@
#include "ItemIconCache.h"
#include <QFile>
#include <QPainter>
#include <QSvgRenderer>
ItemIconCache::ItemIconCache(const QString& iconDir)
: m_iconDir(iconDir)
{
}
const QByteArray& ItemIconCache::getSvg(const std::string& itemId)
{
const std::map<std::string, QByteArray>::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<std::string, int> key(itemId, sizePx);
const std::map<std::pair<std::string, int>, QPixmap>::const_iterator cached =
m_pixmapCache.find(key);
if (cached != m_pixmapCache.end())
{
return cached->second;
}
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;
}

45
src/ui/ItemIconCache.h Normal file
View File

@@ -0,0 +1,45 @@
#pragma once
#include <map>
#include <string>
#include <utility>
#include <QByteArray>
#include <QPixmap>
#include <QString>
// 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 "<item_id>.svg" icon files
// (typically "<configDir>/../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<std::string, QByteArray> m_svgById;
std::map<std::pair<std::string, int>, QPixmap> m_pixmapCache;
};

View File

@@ -323,7 +323,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
bool autoOpenLayout = false; bool autoOpenLayout = false;
std::string chosenSchematic; std::string chosenSchematic;
RecipeSelectionDialog dialog(options, title, this); // Item icons live beside the config dir, mirroring the buildings icon path
// (REQ-UI-ITEM-ICON, REQ-UI-BUILD-ICON).
const QString itemIconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/items");
RecipeSelectionDialog dialog(options, title, itemIconDir, this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value()) if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{ {
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>(); std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();

View File

@@ -3,7 +3,10 @@
#include <cstddef> #include <cstddef>
#include <QGridLayout> #include <QGridLayout>
#include <QIcon>
#include <QPixmap>
#include <QPushButton> #include <QPushButton>
#include <QSize>
#include <QStringList> #include <QStringList>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -11,6 +14,7 @@
#include "BuildingType.h" #include "BuildingType.h"
#include "DisplayName.h" #include "DisplayName.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "ItemIconCache.h"
#include "RecipesConfig.h" #include "RecipesConfig.h"
#include "RecipeTooltip.h" #include "RecipeTooltip.h"
#include "ShipsConfig.h" #include "ShipsConfig.h"
@@ -83,23 +87,38 @@ std::vector<RecipeSelectionOption> buildRecipeSelectionOptions(
{ {
continue; 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, options.push_back({recipe.id,
QString::fromStdString(toDisplayName(recipe.id)), QString::fromStdString(toDisplayName(recipe.id)),
buildRecipeTooltip(recipe)}); buildRecipeTooltip(recipe),
iconItemId});
} }
} }
return options; 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( RecipeSelectionDialog::RecipeSelectionDialog(
const std::vector<RecipeSelectionOption>& options, const std::vector<RecipeSelectionOption>& options,
const QString& title, QWidget* parent) const QString& title, const QString& itemIconDir, QWidget* parent)
: QDialog(parent) : QDialog(parent)
{ {
setWindowTitle(title); setWindowTitle(title);
setModal(true); setModal(true);
ItemIconCache iconCache(itemIconDir);
QVBoxLayout* mainLayout = new QVBoxLayout(this); QVBoxLayout* mainLayout = new QVBoxLayout(this);
QGridLayout* grid = new QGridLayout(); QGridLayout* grid = new QGridLayout();
mainLayout->addLayout(grid); mainLayout->addLayout(grid);
@@ -109,7 +128,19 @@ RecipeSelectionDialog::RecipeSelectionDialog(
{ {
const RecipeSelectionOption& option = options[static_cast<std::size_t>(i)]; const RecipeSelectionOption& option = options[static_cast<std::size_t>(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()) if (!option.tooltip.isEmpty())
{ {
button->setToolTip(option.tooltip); button->setToolTip(option.tooltip);

View File

@@ -20,6 +20,10 @@ struct RecipeSelectionOption
std::string id; std::string id;
QString caption; QString caption;
QString tooltip; 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 // Builds the lock-aware list of selectable options for a production building
@@ -39,8 +43,12 @@ class RecipeSelectionDialog : public QDialog
Q_OBJECT Q_OBJECT
public: 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<RecipeSelectionOption>& options, RecipeSelectionDialog(const std::vector<RecipeSelectionOption>& options,
const QString& title, QWidget* parent = nullptr); const QString& title, const QString& itemIconDir,
QWidget* parent = nullptr);
std::optional<std::string> getChosenId() const; std::optional<std::string> getChosenId() const;