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

This commit is contained in:
2026-07-23 21:13:37 +02:00
parent 0bba7686e6
commit 79b79ab7c3
9 changed files with 313 additions and 18 deletions

View File

@@ -453,13 +453,14 @@ The screen is divided into two columns: a main column (75% width) containing the
``` ```
- REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right. - REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
- REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The hover tooltip (REQ-UI-BLOCKS-TOOLTIP) applies in either form.
- REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP). - REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
- REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP). - REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
- REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs. - REQ-UI-BOSS-STATUS: The header bar displays, to the left of the speed buttons, the current boss wave counter (REQ-WAV-BOSS-COUNTER) and the time remaining on the boss countdown (REQ-WAV-BOSS-COUNTDOWN). The boss wave counter is shown as `Boss Wave #<x>` and the countdown as `Next boss: <M:SS>`, where `<M:SS>` is the remaining seconds formatted as whole minutes and two-digit seconds. Both values update continuously as the simulation runs.
- REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 10× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game. - REQ-UI-SPEED: The game speed controls in the header bar are buttons for 0×, 0.5×, 1×, 2×, and 10× speed. The currently active speed is shown as selected. All game simulation (production, movement, threat accumulation, wave timing) scales with the selected speed. 0× pauses the game.
- REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views). - REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views).
- REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other. - REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x> Blocks`, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion. - REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width). - REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom). - REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation. - REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
@@ -555,7 +556,7 @@ The screen is divided into two columns: a main column (75% width) containing the
### Build Button Grid ### Build Button Grid
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. - REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each.
- REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost, e.g. "Belt: 2 Blocks". - REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, e.g. `Belt: 2` then a small block icon. When no icon file exists for `building_block`, the caption falls back to the text form, e.g. "Belt: 2 Blocks".
- REQ-UI-BUILD-ICON: Each build button shows an icon alongside its caption. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-GRID) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button with its caption and no icon; it is not an error. - REQ-UI-BUILD-ICON: Each build button shows an icon alongside its caption. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-GRID) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button with its caption and no icon; it is not an error.
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip with the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). If a building type defines no tooltip text, its button shows no tooltip. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON. - REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip with the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). If a building type defines no tooltip text, its button shows no tooltip. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained. - REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.

View File

@@ -3,12 +3,17 @@
#include <string> #include <string>
#include <QByteArray> #include <QByteArray>
#include <QColor>
#include <QFile> #include <QFile>
#include <QFontMetrics>
#include <QGridLayout> #include <QGridLayout>
#include <QIcon> #include <QIcon>
#include <QPainter> #include <QPainter>
#include <QPaintEvent>
#include <QPalette>
#include <QPixmap> #include <QPixmap>
#include <QPushButton> #include <QPushButton>
#include <QRect>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSignalMapper> #include <QSignalMapper>
#include <QSize> #include <QSize>
@@ -21,6 +26,7 @@
#include "DisplayName.h" #include "DisplayName.h"
#include "EventManager.h" #include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h" #include "ExitBuilderModeRequestedEvent.h"
#include "ItemIconCache.h"
#include "Simulation.h" #include "Simulation.h"
namespace namespace
@@ -68,15 +74,123 @@ namespace
icon.addPixmap(renderChip(greyed.toUtf8()), QIcon::Disabled); icon.addPixmap(renderChip(greyed.toUtf8()), QIcon::Disabled);
return icon; 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, 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) : QWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
, m_iconDir(iconDir) , m_iconDir(iconDir)
, m_itemIcons(std::make_unique<ItemIconCache>(
QString::fromStdString(itemsIconDir)))
{ {
QGridLayout* layout = new QGridLayout(this); QGridLayout* layout = new QGridLayout(this);
layout->setSpacing(4); layout->setSpacing(4);
@@ -87,6 +201,13 @@ BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
int row = 0; int row = 0;
const int kCols = 3; 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) for (const BuildingDef& def : config->buildings.buildings)
{ {
if (!def.playerPlaceable) if (!def.playerPlaceable)
@@ -107,16 +228,28 @@ BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
const QString name = (def.type == BuildingType::TunnelEntry) const QString name = (def.type == BuildingType::TunnelEntry)
? tr("Tunnel") ? tr("Tunnel")
: QString::fromStdString(toDisplayName(def.id)); : 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 // Icon file name matches the building id (REQ-UI-BUILD-GRID); Tunnel Entry's
// "tunnel_entry.svg" serves the shared Tunnel button. // "tunnel_entry.svg" serves the shared Tunnel button.
const QString iconPath = QString::fromStdString(m_iconDir) + "/" const QString iconPath = QString::fromStdString(m_iconDir) + "/"
+ QString::fromStdString(def.id) + ".svg"; + 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) if (def.tooltip)
{ {
btn->setToolTip(QString::fromStdString(*def.tooltip)); btn->setToolTip(QString::fromStdString(*def.tooltip));

View File

@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <map> #include <map>
#include <memory>
#include <optional> #include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -18,6 +19,7 @@
class QPushButton; class QPushButton;
class Simulation; class Simulation;
class ItemIconCache;
class BuildButtonGrid : public QWidget, class BuildButtonGrid : public QWidget,
public CombinedEventHandler<BuilderModeExitedEvent, public CombinedEventHandler<BuilderModeExitedEvent,
@@ -30,9 +32,12 @@ class BuildButtonGrid : public QWidget,
public: public:
// iconDir is the directory holding the per-building "<id>.svg" chip icons // 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, 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; ~BuildButtonGrid() override;
void clearActiveButton(); void clearActiveButton();
@@ -60,6 +65,7 @@ private:
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
std::string m_iconDir; std::string m_iconDir;
std::unique_ptr<ItemIconCache> m_itemIcons;
std::vector<BuildingType> m_types; std::vector<BuildingType> m_types;
std::vector<QPushButton*> m_buttons; std::vector<QPushButton*> m_buttons;
std::map<BuildingType, int> m_costs; std::map<BuildingType, int> m_costs;

View File

@@ -16,6 +16,7 @@ SET(HDRS
${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 ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -36,5 +37,6 @@ SET(SRCS
${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 ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -3,34 +3,51 @@
#include <cmath> #include <cmath>
#include <string> #include <string>
#include <QFontMetrics>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QIcon>
#include <QLabel> #include <QLabel>
#include <QPalette>
#include <QPushButton> #include <QPushButton>
#include <QSignalMapper> #include <QSignalMapper>
#include <QSize>
#include "Command.h" #include "Command.h"
#include "CommandRequestedEvent.h" #include "CommandRequestedEvent.h"
#include "EventManager.h" #include "EventManager.h"
#include "IconCaption.h"
#include "ItemIconCache.h"
#include "SpeedChangeRequestedEvent.h" #include "SpeedChangeRequestedEvent.h"
#include "Tick.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 double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
const int HeaderBar::kSpeedCount = 5; 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) : QWidget(parent)
, m_itemIcons(std::make_unique<ItemIconCache>(
QString::fromStdString(itemsIconDir)))
{ {
QHBoxLayout* layout = new QHBoxLayout(this); QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(8, 4, 8, 4); layout->setContentsMargins(8, 4, 8, 4);
layout->setSpacing(8); layout->setSpacing(8);
m_timeLabel = new QLabel("00:00", this); 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) if (config->world.buildingBlocksTooltip)
{ {
m_blocksLabel->setToolTip( m_blocksLabel->setToolTip(
QString::fromStdString(*config->world.buildingBlocksTooltip)); QString::fromStdString(*config->world.buildingBlocksTooltip));
} }
updateBlocksLabel();
m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this); m_artifactsLabel = new QLabel(tr("Artifacts: 0/?"), this);
if (config->world.artifactTooltip) 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) void HeaderBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event)
{ {
m_blocks = event->blocks; m_blocks = event->blocks;
m_blocksLabel->setText(tr("Building Blocks: %1").arg(event->blocks)); updateBlocksLabel();
updateExpandButton(); updateExpandButton();
} }
@@ -101,10 +118,58 @@ void HeaderBar::handleEvent(std::shared_ptr<const ExpansionCostChangedEvent> eve
updateExpandButton(); 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() void HeaderBar::updateExpandButton()
{ {
m_expandButton->setText(tr("Expand: %1 Building Blocks").arg(m_expansionCost));
m_expandButton->setEnabled(m_blocks >= 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) void HeaderBar::handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event)

View File

@@ -1,7 +1,10 @@
#pragma once #pragma once
#include <memory>
#include <string>
#include <vector> #include <vector>
#include <QPixmap>
#include <QWidget> #include <QWidget>
#include "ArtifactCountChangedEvent.h" #include "ArtifactCountChangedEvent.h"
@@ -16,6 +19,7 @@
class QLabel; class QLabel;
class QPushButton; class QPushButton;
class ItemIconCache;
class HeaderBar : public QWidget, class HeaderBar : public QWidget,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
@@ -28,7 +32,11 @@ class HeaderBar : public QWidget,
Q_OBJECT Q_OBJECT
public: 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; ~HeaderBar() override;
private slots: private slots:
@@ -46,6 +54,15 @@ private:
// expansion cost and building block stock (REQ-UI-EXPAND-BUTTON). // expansion cost and building block stock (REQ-UI-EXPAND-BUTTON).
void updateExpandButton(); 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_timeLabel;
QLabel* m_blocksLabel; QLabel* m_blocksLabel;
QLabel* m_artifactsLabel; QLabel* m_artifactsLabel;
@@ -53,6 +70,8 @@ private:
QPushButton* m_expandButton; QPushButton* m_expandButton;
std::vector<QPushButton*> m_speedButtons; std::vector<QPushButton*> m_speedButtons;
std::unique_ptr<ItemIconCache> m_itemIcons;
int m_blocks = 0; int m_blocks = 0;
int m_expansionCost = 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")); setWindowTitle(tr("Dota Factory"));
resize(1280, 768); 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_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_replay.get(), this); m_replay.get(), this);
@@ -58,7 +63,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString(); QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel); 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); m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1); sideLayout->addWidget(m_selectedBuildingPanel, 1);