diff --git a/src/ui/BuildButtonGrid.cpp b/src/ui/BuildButtonGrid.cpp index 02d9252..ebb9cc0 100644 --- a/src/ui/BuildButtonGrid.cpp +++ b/src/ui/BuildButtonGrid.cpp @@ -3,12 +3,17 @@ #include #include +#include #include +#include #include #include #include +#include +#include #include #include +#include #include #include #include @@ -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 ".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: "" then the block icon (or " 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(m_blockIcon.width() / iconDpr); + const int iconH = static_cast(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( + 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)); diff --git a/src/ui/BuildButtonGrid.h b/src/ui/BuildButtonGrid.h index 3cc4c62..7a51d56 100644 --- a/src/ui/BuildButtonGrid.h +++ b/src/ui/BuildButtonGrid.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -18,6 +19,7 @@ class QPushButton; class Simulation; +class ItemIconCache; class BuildButtonGrid : public QWidget, public CombinedEventHandler.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 m_itemIcons; std::vector m_types; std::vector m_buttons; std::map m_costs; diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 4f521d8..96d8679 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -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 ) diff --git a/src/ui/HeaderBar.cpp b/src/ui/HeaderBar.cpp index 8fe680a..6c7dadd 100644 --- a/src/ui/HeaderBar.cpp +++ b/src/ui/HeaderBar.cpp @@ -3,34 +3,51 @@ #include #include +#include #include +#include #include +#include #include #include +#include #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( + 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 event) void HeaderBar::handleEvent(std::shared_ptr 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 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(normal.width() / dpr), + static_cast(normal.height() / dpr))); } void HeaderBar::handleEvent(std::shared_ptr event) diff --git a/src/ui/HeaderBar.h b/src/ui/HeaderBar.h index 1438e19..da9869c 100644 --- a/src/ui/HeaderBar.h +++ b/src/ui/HeaderBar.h @@ -1,7 +1,10 @@ #pragma once +#include +#include #include +#include #include #include "ArtifactCountChangedEvent.h" @@ -16,6 +19,7 @@ class QLabel; class QPushButton; +class ItemIconCache; class HeaderBar : public QWidget, public CombinedEventHandler` with + // the building_block icon after it, or the `Stock: 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 m_speedButtons; + std::unique_ptr m_itemIcons; + int m_blocks = 0; int m_expansionCost = 0; diff --git a/src/ui/IconCaption.cpp b/src/ui/IconCaption.cpp new file mode 100644 index 0000000..f703cd6 --- /dev/null +++ b/src/ui/IconCaption.cpp @@ -0,0 +1,48 @@ +#include "IconCaption.h" + +#include +#include +#include +#include + +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(icon.width() / iconDpr); + const int iconH = icon.isNull() ? 0 + : static_cast(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(width * dpr), static_cast(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; +} diff --git a/src/ui/IconCaption.h b/src/ui/IconCaption.h new file mode 100644 index 0000000..9888033 --- /dev/null +++ b/src/ui/IconCaption.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include + +// 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); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index b846322..f572a29 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -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);