Allow to draw produced-item icons in recipe dialog and game world
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ struct RecipeDef
|
||||
std::vector<RecipeIngredient> inputs;
|
||||
std::vector<RecipeOutput> 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<std::string> 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<ItemIconCache>(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<void(const ItemType&, QPointF)> drawItem =
|
||||
[&](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(
|
||||
QVector2D(static_cast<float>(worldPos.x()),
|
||||
static_cast<float>(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<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)
|
||||
{
|
||||
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<std::string, ItemVisuals>::const_iterator it =
|
||||
m_visuals->items.find(vi.type.id);
|
||||
if (it == m_visuals->items.end()) { return; }
|
||||
|
||||
const QPointF center = worldToWidget(
|
||||
QVector2D(static_cast<float>(vi.worldPos.x()),
|
||||
static_cast<float>(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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <QElapsedTimer>
|
||||
#include <QOpenGLWidget>
|
||||
#include <QPoint>
|
||||
#include <QPointF>
|
||||
#include <QRectF>
|
||||
#include <QTimer>
|
||||
#include <QVector2D>
|
||||
@@ -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<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.
|
||||
CommandManager m_commandManager;
|
||||
// A Reset command was enqueued; reset the view after the next drain applies it.
|
||||
|
||||
64
src/ui/ItemIconCache.cpp
Normal file
64
src/ui/ItemIconCache.cpp
Normal 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
45
src/ui/ItemIconCache.h
Normal 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;
|
||||
};
|
||||
@@ -323,7 +323,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
||||
|
||||
bool autoOpenLayout = false;
|
||||
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())
|
||||
{
|
||||
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
#include <cstddef>
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QIcon>
|
||||
#include <QPixmap>
|
||||
#include <QPushButton>
|
||||
#include <QSize>
|
||||
#include <QStringList>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
@@ -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<RecipeSelectionOption> 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<RecipeSelectionOption>& 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<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())
|
||||
{
|
||||
button->setToolTip(option.tooltip);
|
||||
|
||||
@@ -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<RecipeSelectionOption>& options,
|
||||
const QString& title, QWidget* parent = nullptr);
|
||||
const QString& title, const QString& itemIconDir,
|
||||
QWidget* parent = nullptr);
|
||||
|
||||
std::optional<std::string> getChosenId() const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user