Show building_block icon in header stock, expand button, build costs

Header stock reads "Stock: <n>" + block icon; the expand button and each
build button show the block icon in place of the "Blocks" word
(REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON, REQ-UI-BUILD-COST).

New IconCaption::renderCaptionWithIcon composes "text + trailing icon"
into one pixmap (Qt buttons/labels can't place an icon after text). The
header label uses it as a pixmap; the expand button as its QIcon (Normal
+ greyed Disabled). Build buttons need both the building chip and the
block icon, so a custom-painted BuildButton draws the whole face,
adaptive to width and greying when unaffordable. All paths fall back to
text when no building_block icon exists. Item icon dir threaded through
MainWindow to HeaderBar and BuildButtonGrid.

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 21:11:14 +02:00
parent b9b812e264
commit b631de5ea3
8 changed files with 310 additions and 16 deletions

View File

@@ -3,12 +3,17 @@
#include <string>
#include <QByteArray>
#include <QColor>
#include <QFile>
#include <QFontMetrics>
#include <QGridLayout>
#include <QIcon>
#include <QPainter>
#include <QPaintEvent>
#include <QPalette>
#include <QPixmap>
#include <QPushButton>
#include <QRect>
#include <QRegularExpression>
#include <QSignalMapper>
#include <QSize>
@@ -21,6 +26,7 @@
#include "DisplayName.h"
#include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "ItemIconCache.h"
#include "Simulation.h"
namespace
@@ -68,15 +74,123 @@ namespace
icon.addPixmap(renderChip(greyed.toUtf8()), QIcon::Disabled);
return icon;
}
// Normal and grey-background chip pixmaps for a "<id>.svg" file, using the same
// recolor rule as loadBuildingIcon. Empty pixmaps if the file cannot be read.
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
ChipPixmaps loadChipPixmaps(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) { return {}; }
const QByteArray svg = file.readAll();
ChipPixmaps result;
result.normal = renderChip(svg);
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());
return result;
}
// A build button that paints its own face — chip icon at the left, the building
// name above the cost, and the building_block item icon after the cost number in
// place of the "Blocks" word (REQ-UI-BUILD-COST, REQ-UI-BUILD-ICON). Custom paint
// (rather than the native icon+text) is needed because a QPushButton holds only
// one icon; this stays adaptive to the button width and greys itself when the
// button is disabled/unaffordable (REQ-UI-BUILD-DISABLED).
class BuildButton : public QPushButton
{
public:
BuildButton(const ChipPixmaps& chip, const QString& name,
const QString& costText, const QPixmap& blockIcon, QWidget* parent)
: QPushButton(parent)
, m_chip(chip)
, m_name(name)
, m_costText(costText)
, m_blockIcon(blockIcon)
{
}
protected:
void paintEvent(QPaintEvent* event) override
{
QPushButton::paintEvent(event); // frame, checked/hover state
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
const bool on = isEnabled();
const QRect area = rect().adjusted(6, 4, -6, -4);
const int chipSize = kIconSize.width();
const QPixmap& chip = on ? m_chip.normal : m_chip.grey;
if (!chip.isNull())
{
painter.drawPixmap(
QRect(area.x(), area.y() + (area.height() - chipSize) / 2,
chipSize, chipSize), chip);
}
const QRect textArea(area.x() + chipSize + 6, area.y(),
area.width() - chipSize - 6, area.height());
const QFontMetrics metrics(font());
const int lineHeight = metrics.height();
painter.setFont(font());
painter.setPen(palette().color(on ? QPalette::Active : QPalette::Disabled,
QPalette::ButtonText));
// Name fills everything above the bottom cost line (word-wrapped).
painter.drawText(
QRect(textArea.x(), textArea.y(),
textArea.width(), textArea.height() - lineHeight),
Qt::AlignLeft | Qt::AlignVCenter | Qt::TextWordWrap, m_name);
// Cost line: "<n>" then the block icon (or "<n> Blocks" when no icon).
const int costY = textArea.bottom() - lineHeight + 1;
if (m_blockIcon.isNull())
{
painter.drawText(
QRect(textArea.x(), costY, textArea.width(), lineHeight),
Qt::AlignLeft | Qt::AlignVCenter,
QObject::tr("%1 Blocks").arg(m_costText));
return;
}
const int costWidth = metrics.horizontalAdvance(m_costText);
painter.drawText(QRect(textArea.x(), costY, costWidth, lineHeight),
Qt::AlignLeft | Qt::AlignVCenter, m_costText);
const qreal iconDpr = m_blockIcon.devicePixelRatio();
const int iconW = static_cast<int>(m_blockIcon.width() / iconDpr);
const int iconH = static_cast<int>(m_blockIcon.height() / iconDpr);
if (!on) { painter.setOpacity(0.45); }
painter.drawPixmap(
QRect(textArea.x() + costWidth + 4, costY + (lineHeight - iconH) / 2,
iconW, iconH), m_blockIcon);
}
private:
ChipPixmaps m_chip;
QString m_name;
QString m_costText;
QPixmap m_blockIcon;
};
}
BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
const std::string& iconDir, QWidget* parent)
const std::string& iconDir,
const std::string& itemsIconDir, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_iconDir(iconDir)
, m_itemIcons(std::make_unique<ItemIconCache>(
QString::fromStdString(itemsIconDir)))
{
QGridLayout* layout = new QGridLayout(this);
layout->setSpacing(4);
@@ -87,6 +201,13 @@ BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
int row = 0;
const int kCols = 3;
// Block icon shown in each button's cost line (REQ-UI-BUILD-COST); null when no
// building_block icon exists, in which case buttons fall back to text costs.
const bool hasBlockIcon = m_itemIcons->hasIcon("building_block");
const QPixmap blockIcon = hasBlockIcon
? m_itemIcons->getPixmap("building_block", QFontMetrics(font()).height())
: QPixmap();
for (const BuildingDef& def : config->buildings.buildings)
{
if (!def.playerPlaceable)
@@ -107,16 +228,28 @@ BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
const QString name = (def.type == BuildingType::TunnelEntry)
? tr("Tunnel")
: QString::fromStdString(toDisplayName(def.id));
const QString label = name + "\n" + tr("%1 Building Blocks").arg(def.cost);
QPushButton* btn = new QPushButton(label, this);
btn->setCheckable(true);
btn->setFixedHeight(48);
// Icon file name matches the building id (REQ-UI-BUILD-GRID); Tunnel Entry's
// "tunnel_entry.svg" serves the shared Tunnel button.
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
+ QString::fromStdString(def.id) + ".svg";
btn->setIcon(loadBuildingIcon(iconPath));
btn->setIconSize(kIconSize);
QPushButton* btn = nullptr;
if (hasBlockIcon)
{
// Custom-painted button showing the cost with the block icon in place of
// the "Blocks" word (REQ-UI-BUILD-COST).
btn = new BuildButton(loadChipPixmaps(iconPath), name,
QString::number(def.cost), blockIcon, this);
}
else
{
// Fallback: native chip icon + text cost when no block icon exists.
btn = new QPushButton(name + "\n" + tr("%1 Blocks").arg(def.cost), this);
btn->setIcon(loadBuildingIcon(iconPath));
btn->setIconSize(kIconSize);
}
btn->setCheckable(true);
btn->setFixedHeight(48);
if (def.tooltip)
{
btn->setToolTip(QString::fromStdString(*def.tooltip));

View File

@@ -1,6 +1,7 @@
#pragma once
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <vector>
@@ -18,6 +19,7 @@
class QPushButton;
class Simulation;
class ItemIconCache;
class BuildButtonGrid : public QWidget,
public CombinedEventHandler<BuilderModeExitedEvent,
@@ -30,9 +32,12 @@ class BuildButtonGrid : public QWidget,
public:
// iconDir is the directory holding the per-building "<id>.svg" chip icons
// (REQ-UI-BUILD-GRID); read from disk at runtime, like the config files.
// (REQ-UI-BUILD-GRID); itemsIconDir holds the per-item icons and supplies the
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Both are
// read from disk at runtime, like the config files.
BuildButtonGrid(Simulation* sim, const GameConfig* config,
const std::string& iconDir, QWidget* parent = nullptr);
const std::string& iconDir, const std::string& itemsIconDir,
QWidget* parent = nullptr);
~BuildButtonGrid() override;
void clearActiveButton();
@@ -60,6 +65,7 @@ private:
Simulation* m_sim;
const GameConfig* m_config;
std::string m_iconDir;
std::unique_ptr<ItemIconCache> m_itemIcons;
std::vector<BuildingType> m_types;
std::vector<QPushButton*> m_buttons;
std::map<BuildingType, int> m_costs;

View File

@@ -16,6 +16,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h
PARENT_SCOPE
)
@@ -36,5 +37,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp
PARENT_SCOPE
)

View File

@@ -3,34 +3,51 @@
#include <cmath>
#include <string>
#include <QFontMetrics>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QPalette>
#include <QPushButton>
#include <QSignalMapper>
#include <QSize>
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "EventManager.h"
#include "IconCaption.h"
#include "ItemIconCache.h"
#include "SpeedChangeRequestedEvent.h"
#include "Tick.h"
namespace
{
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
// word in the header stock and expand button (REQ-UI-BLOCKS-ICON).
const char* const kBlockItemId = "building_block";
}
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
const int HeaderBar::kSpeedCount = 5;
HeaderBar::HeaderBar(const GameConfig* config, QWidget* parent)
HeaderBar::HeaderBar(const GameConfig* config, const std::string& itemsIconDir,
QWidget* parent)
: QWidget(parent)
, m_itemIcons(std::make_unique<ItemIconCache>(
QString::fromStdString(itemsIconDir)))
{
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(8, 4, 8, 4);
layout->setSpacing(8);
m_timeLabel = new QLabel("00:00", this);
m_blocksLabel = new QLabel(tr("Building Blocks: 0"), this);
m_blocksLabel = new QLabel(this);
if (config->world.buildingBlocksTooltip)
{
m_blocksLabel->setToolTip(
QString::fromStdString(*config->world.buildingBlocksTooltip));
}
updateBlocksLabel();
m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this);
if (config->world.artifactTooltip)
{
@@ -91,7 +108,7 @@ void HeaderBar::handleEvent(std::shared_ptr<const TickAdvancedEvent> event)
void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
{
m_blocks = event->blocks;
m_blocksLabel->setText(tr("Building Blocks: %1").arg(event->blocks));
updateBlocksLabel();
updateExpandButton();
}
@@ -101,10 +118,58 @@ void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> eve
updateExpandButton();
}
QPixmap HeaderBar::blockIcon() const
{
if (!m_itemIcons->hasIcon(kBlockItemId)) { return QPixmap(); }
// Sized to the header text height so it sits inline with the caption.
const int sizePx = QFontMetrics(font()).height();
return m_itemIcons->getPixmap(kBlockItemId, sizePx);
}
void HeaderBar::updateBlocksLabel()
{
const QPixmap icon = blockIcon();
if (icon.isNull())
{
// Fallback text form when no building_block icon exists (REQ-UI-BLOCKS-ICON).
m_blocksLabel->setText(tr("Stock: %1 Blocks").arg(m_blocks));
return;
}
m_blocksLabel->setPixmap(renderCaptionWithIcon(
tr("Stock: %1").arg(m_blocks), icon, font(),
m_blocksLabel->palette().color(QPalette::WindowText)));
}
void HeaderBar::updateExpandButton()
{
m_expandButton->setText(tr("Expand: %1 Building Blocks").arg(m_expansionCost));
m_expandButton->setEnabled(m_blocks >= m_expansionCost);
const QPixmap icon = blockIcon();
if (icon.isNull())
{
// Fallback text form when no building_block icon exists (REQ-UI-EXPAND-BUTTON).
m_expandButton->setIcon(QIcon());
m_expandButton->setText(tr("Expand: %1 Blocks").arg(m_expansionCost));
return;
}
const QString text = tr("Expand: %1").arg(m_expansionCost);
const QPalette& pal = m_expandButton->palette();
const QPixmap normal = renderCaptionWithIcon(
text, icon, m_expandButton->font(), pal.color(QPalette::ButtonText));
const QPixmap greyed = renderCaptionWithIcon(
text, icon, m_expandButton->font(),
pal.color(QPalette::Disabled, QPalette::ButtonText));
QIcon buttonIcon;
buttonIcon.addPixmap(normal, QIcon::Normal);
buttonIcon.addPixmap(greyed, QIcon::Disabled);
m_expandButton->setText(QString());
m_expandButton->setIcon(buttonIcon);
const qreal dpr = normal.devicePixelRatio();
m_expandButton->setIconSize(QSize(
static_cast<int>(normal.width() / dpr),
static_cast<int>(normal.height() / dpr)));
}
void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)

View File

@@ -1,7 +1,10 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include <QPixmap>
#include <QWidget>
#include "ArtifactCountChangedEvent.h"
@@ -16,6 +19,7 @@
class QLabel;
class QPushButton;
class ItemIconCache;
class HeaderBar : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
@@ -28,7 +32,11 @@ class HeaderBar : public QWidget,
Q_OBJECT
public:
explicit HeaderBar(const GameConfig* config, QWidget* parent = nullptr);
// itemsIconDir holds the per-item icon SVGs (REQ-UI-ITEM-ICON); used to show the
// building_block icon in the stock display and expand button (REQ-UI-BLOCKS-ICON,
// REQ-UI-EXPAND-BUTTON).
HeaderBar(const GameConfig* config, const std::string& itemsIconDir,
QWidget* parent = nullptr);
~HeaderBar() override;
private slots:
@@ -46,6 +54,15 @@ private:
// expansion cost and building block stock (REQ-UI-EXPAND-BUTTON).
void updateExpandButton();
// Refreshes the building blocks stock display from m_blocks: `Stock: <n>` with
// the building_block icon after it, or the `Stock: <n> Blocks` text fallback when
// no icon file exists (REQ-UI-BLOCKS-ICON).
void updateBlocksLabel();
// The building_block icon at the header's text height, or a null pixmap when no
// icon file exists. Loaded once via m_itemIcons on first use.
QPixmap blockIcon() const;
QLabel* m_timeLabel;
QLabel* m_blocksLabel;
QLabel* m_artifactsLabel;
@@ -53,6 +70,8 @@ private:
QPushButton* m_expandButton;
std::vector<QPushButton*> m_speedButtons;
std::unique_ptr<ItemIconCache> m_itemIcons;
int m_blocks = 0;
int m_expansionCost = 0;

48
src/ui/IconCaption.cpp Normal file
View File

@@ -0,0 +1,48 @@
#include "IconCaption.h"
#include <QFontMetrics>
#include <QGuiApplication>
#include <QPainter>
#include <QRect>
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
const QFont& font, const QColor& textColor)
{
const QFontMetrics metrics(font);
const int textWidth = metrics.horizontalAdvance(text);
const int gap = icon.isNull() ? 0 : 6;
// The icon is drawn at the caption's device-independent pixel size; the source
// pixmap may be larger (rasterized at a target size / device pixel ratio), so
// divide by its own dpr to get its logical size.
const qreal iconDpr = icon.isNull() ? 1.0 : icon.devicePixelRatio();
const int iconW = icon.isNull() ? 0
: static_cast<int>(icon.width() / iconDpr);
const int iconH = icon.isNull() ? 0
: static_cast<int>(icon.height() / iconDpr);
const int width = textWidth + gap + iconW;
const int height = qMax(metrics.height(), iconH);
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QPixmap pixmap(static_cast<int>(width * dpr), static_cast<int>(height * dpr));
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.setFont(font);
painter.setPen(textColor);
painter.drawText(QRect(0, 0, textWidth, height),
Qt::AlignLeft | Qt::AlignVCenter, text);
if (!icon.isNull())
{
const int iconX = textWidth + gap;
const int iconY = (height - iconH) / 2;
painter.drawPixmap(QRect(iconX, iconY, iconW, iconH), icon);
}
return pixmap;
}

16
src/ui/IconCaption.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include <QColor>
#include <QFont>
#include <QPixmap>
#include <QString>
// Renders `text` followed by `icon` to its right, vertically centered, onto a
// transparent pixmap sized to fit both (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
// REQ-UI-EXPAND-BUTTON). Used where a caption must show an inline item icon in
// place of a trailing word — something QPushButton/QLabel cannot do natively.
//
// `textColor` and `font` come from the target widget so the caption stays
// theme-correct; the result is devicePixelRatio-aware so text and icon stay crisp.
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
const QFont& font, const QColor& textColor);

View File

@@ -42,7 +42,12 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
setWindowTitle(tr("Dota Factory"));
resize(1280, 768);
m_headerBar = new HeaderBar(&sim->getConfig(), this);
// 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();
m_headerBar = new HeaderBar(&sim->getConfig(), itemsIconDir, this);
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_replay.get(), this);
@@ -58,7 +63,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, itemsIconDir, m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1);