float the build buttons as a horizontal bar over the game world and show key bindings inside build buttons

This commit is contained in:
2026-08-06 08:28:40 +02:00
parent f39fe9a118
commit c1af58d80c
14 changed files with 575 additions and 477 deletions

459
src/ui/BuildButtonBar.cpp Normal file
View File

@@ -0,0 +1,459 @@
#include "BuildButtonBar.h"
#include <string>
#include <QByteArray>
#include <QColor>
#include <QFile>
#include <QFont>
#include <QFontMetrics>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QIcon>
#include <QPainter>
#include <QPalette>
#include <QPixmap>
#include <QPushButton>
#include <QRect>
#include <QRegularExpression>
#include <QSignalMapper>
#include <QSize>
#include <QString>
#include <QSvgRenderer>
#include "BuildingType.h"
#include "BuildingTypeSelectedEvent.h"
#include "DeconstructModeToggleRequestedEvent.h"
#include "DisplayName.h"
#include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "IconCaption.h"
#include "InputMapper.h"
#include "ItemIconCache.h"
#include "Simulation.h"
namespace
{
// Size the SVG chips are drawn at on a button face, in device-independent pixels.
const QSize kIconSize(32, 32);
// Minimum width of a button face. The building-type buttons all come out at this
// width, so the row is uniform; a face whose caption is wider than this — the
// Deconstruct button's name — grows to fit rather than clipping
// (REQ-UI-DECONSTRUCT-BUTTON).
const int kFaceMinWidthPx = 48;
// Gap between the last building button and the Deconstruct button, which toggles
// a mode rather than selecting a building type (REQ-UI-DECONSTRUCT-BUTTON).
const int kDeconstructGapPx = 16;
// Distance from the bar to the bottom edge of the game world view
// (REQ-UI-BUILD-BAR).
const int kBottomMarginPx = 8;
// Gap between the chip icon and the cost line on a button face.
const int kFaceGapPx = 2;
// Rasterizes a chip SVG straight at its on-screen size times the device pixel
// ratio, so it stays crisp without a downscale step.
QPixmap renderChip(const QByteArray& svg)
{
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QSvgRenderer renderer(svg);
QPixmap pixmap(static_cast<int>(kIconSize.width() * dpr),
static_cast<int>(kIconSize.height() * dpr));
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
renderer.render(&painter);
return pixmap;
}
// Normal and grey-background chip pixmaps for a "<id>.svg" file. Empty pixmaps if
// the file cannot be read; the caller then falls back to a name caption
// (REQ-UI-BUILD-ICON).
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);
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
// rounded background rect; the white glyph uses fill="none" and is left alone.
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 pixmap's size in device-independent pixels. The pixmaps composed here are
// rasterized at the device pixel ratio, so their raw size is not their layout size.
QSize getLogicalSize(const QPixmap& pixmap)
{
if (pixmap.isNull()) { return QSize(0, 0); }
const qreal dpr = pixmap.devicePixelRatio();
return QSize(static_cast<int>(pixmap.width() / dpr),
static_cast<int>(pixmap.height() / dpr));
}
// One button face: the hotkey badge in the top-left corner, the chip icon centered
// below it, and the caption — the cost, or the Deconstruct name — centered at the
// bottom (REQ-UI-BUILD-COST). The three are composed into a single pixmap because
// a QPushButton holds only one icon.
QPixmap composeButtonFace(const QString& hotkeyLabel, const QPixmap& chip,
const QPixmap& caption, const QFont& badgeFont,
const QColor& badgeColor)
{
const QSize chipSize = getLogicalSize(chip);
const QSize captionSize = getLogicalSize(caption);
// The badge row is kept even for a building type without a hotkey, so buttons
// stay the same height and the row reads as one strip (REQ-UI-BUILD-COST).
const int badgeHeight = QFontMetrics(badgeFont).height();
const int width = qMax(kFaceMinWidthPx, qMax(chipSize.width(), captionSize.width()));
const int height = badgeHeight + chipSize.height() + kFaceGapPx + captionSize.height();
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QPixmap face(static_cast<int>(width * dpr), static_cast<int>(height * dpr));
face.setDevicePixelRatio(dpr);
face.fill(Qt::transparent);
QPainter painter(&face);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
if (!hotkeyLabel.isEmpty())
{
painter.setFont(badgeFont);
painter.setPen(badgeColor);
painter.drawText(QRect(0, 0, width, badgeHeight),
Qt::AlignLeft | Qt::AlignVCenter, hotkeyLabel);
}
painter.drawPixmap((width - chipSize.width()) / 2, badgeHeight, chip);
painter.drawPixmap((width - captionSize.width()) / 2,
badgeHeight + chipSize.height() + kFaceGapPx, caption);
return face;
}
// A composed button face and the size to show it at; the button needs both, and
// only the composer knows the size it arrived at.
struct ButtonFace { QIcon icon; QSize size; };
// The two-mode face of one build button. The modes differ only in the chip variant
// and the text color, so a disabled (unaffordable) button greys itself when Qt
// swaps the pixmap, with no extra work in updateAffordability()
// (REQ-UI-BUILD-DISABLED).
ButtonFace buildButtonFace(const ChipPixmaps& chip, const QString& hotkeyLabel,
const QString& name, const QString& captionText,
const QPixmap& blockIcon, const QFont& font,
const QPalette& palette)
{
// Full button size and bold: at a smaller size the badge was hard to read and
// its arrow glyph illegible. It stays dimmed in both modes instead, so it
// reads as a reminder without competing with the cost.
QFont badgeFont = font;
badgeFont.setBold(true);
const QColor badgeColor = palette.color(QPalette::Disabled, QPalette::ButtonText);
ButtonFace result;
for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled })
{
const bool enabled = (mode == QIcon::Normal);
const QColor textColor = palette.color(
enabled ? QPalette::Active : QPalette::Disabled, QPalette::ButtonText);
const QPixmap& chipPixmap = enabled ? chip.normal : chip.grey;
// A missing "<id>.svg" puts the building name where the chip would be, so
// the button stays identifiable in an icon-only bar (REQ-UI-BUILD-ICON).
const QPixmap middle = chipPixmap.isNull()
? renderCaptionWithIcon(name, QPixmap(), font, textColor)
: chipPixmap;
const QPixmap caption =
renderCaptionWithIcon(captionText, blockIcon, font, textColor);
const QPixmap face =
composeButtonFace(hotkeyLabel, middle, caption, badgeFont, badgeColor);
result.icon.addPixmap(face, mode);
// Both modes compose to the same size; keeping the larger is only a guard
// against a fallback name caption widening one of them.
result.size = result.size.expandedTo(getLogicalSize(face));
}
return result;
}
}
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
const std::string& iconDir,
ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_iconDir(iconDir)
, m_itemIcons(itemIcons)
{
// The bar floats over the rendered world rather than sitting in a panel, so it
// brings its own opaque background to stay legible over any world content
// (REQ-UI-BUILD-BAR). Palette colors keep it consistent with the buttons it holds
// and with the side panels; this is widget chrome, not world rendering, so it is
// deliberately not a visuals.toml color.
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral(
"BuildButtonBar { background-color: palette(window);"
" border: 1px solid palette(mid); border-radius: 4px; }"));
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setSpacing(4);
layout->setContentsMargins(6, 4, 6, 4);
QSignalMapper* mapper = new QSignalMapper(this);
// Block icon shown to the right of each button's cost (REQ-UI-BUILD-COST); null
// when no building_block icon exists, in which case the cost is the bare number.
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
: QPixmap();
for (const BuildingDef& def : config->buildings.buildings)
{
if (!def.playerPlaceable)
{
continue;
}
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
// reached through the unified tunnel build mode, not its own button
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-BAR). Both stay player-placeable so
// blueprints and cost totals still account for exits.
if (def.type == BuildingType::TunnelExit)
{
continue;
}
m_types.push_back(def.type);
m_costs[def.type] = def.cost;
const QString name = (def.type == BuildingType::TunnelEntry)
? tr("Tunnel")
: QString::fromStdString(toDisplayName(def.id));
// Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's
// "tunnel_entry.svg" serves the shared Tunnel button.
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
+ QString::fromStdString(def.id) + ".svg";
const ButtonFace face = buildButtonFace(
loadChipPixmaps(iconPath), InputMapper::getBuildHotkeyLabel(def.type), name,
QString::number(def.cost), blockIcon, font(), palette());
QPushButton* btn = new QPushButton(this);
btn->setIcon(face.icon);
btn->setIconSize(face.size);
btn->setCheckable(true);
// The button face carries no name (REQ-UI-BUILD-COST), so the tooltip always
// leads with it and adds the config description when there is one
// (REQ-UI-BUILD-TOOLTIP).
btn->setToolTip(def.tooltip
? QStringLiteral("%1\n%2").arg(name, QString::fromStdString(*def.tooltip))
: name);
layout->addWidget(btn);
const int idx = static_cast<int>(m_buttons.size());
m_buttons.push_back(btn);
mapper->setMapping(btn, idx);
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
}
connect(mapper, qOverload<int>(&QSignalMapper::mapped), this, &BuildButtonBar::onBuildButton);
// Set apart from the building-type buttons by a gap, because it toggles a mode
// rather than selecting a building type (REQ-UI-DECONSTRUCT-BUTTON). A fixed
// spacer rather than a stretch: the bar is sized to its contents, so there is no
// right edge for a stretch to push against.
layout->addSpacing(kDeconstructGapPx);
// Having no cost, it shows its name where the building buttons show theirs
// (REQ-UI-DECONSTRUCT-BUTTON), and its Q toggle as the badge (REQ-UI-HOTKEYS).
const ButtonFace deconstructFace = buildButtonFace(
loadChipPixmaps(QString::fromStdString(m_iconDir) + "/deconstruct.svg"),
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
font(), palette());
m_deconstructButton = new QPushButton(this);
m_deconstructButton->setCheckable(true);
m_deconstructButton->setIcon(deconstructFace.icon);
m_deconstructButton->setIconSize(deconstructFace.size);
// Refund tooltip composed from world.refund_percentage (REQ-UI-DECONSTRUCT-BUTTON,
// REQ-BLD-DECONSTRUCT). A finished building refunds the configured percentage; a
// construction site removed before it is built is refunded in full. When the
// percentage is 100% both cases coincide, so the tooltip is simplified to one case.
const int refundPercentage = m_config->world.refundPercentage;
const QString deconstructTooltip = (refundPercentage >= 100)
? tr("Deconstruct buildings. Refunds %1% of the building block cost.")
.arg(refundPercentage)
: tr("Deconstruct buildings. A finished building refunds %1% of its building "
"block cost once removed; a construction site removed before it is built "
"is refunded in full.")
.arg(refundPercentage);
m_deconstructButton->setToolTip(deconstructTooltip);
layout->addWidget(m_deconstructButton);
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeToggleRequestedEvent>());
});
updateVisibility();
registerForEvents();
}
BuildButtonBar::~BuildButtonBar()
{
unregisterForEvents();
}
void BuildButtonBar::anchorTo(const QRect& worldViewRect)
{
m_viewRect = worldViewRect;
recenter();
}
void BuildButtonBar::clearActiveButton()
{
if (m_activeIndex)
{
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex.reset();
}
void BuildButtonBar::updateAffordability()
{
const int buildingBlocks = m_sim->getBuildingBlocksStock();
// If the currently selected tool can no longer be afforded, exit builder mode
// before recomputing button states so it does not stay selected. Clearing the
// active index first lets the loop below disable the now-unaffordable button.
if (m_activeIndex)
{
const BuildingType activeType = m_types[*m_activeIndex];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
const int cost = (it != m_costs.end()) ? it->second : 0;
if (buildingBlocks < cost)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
}
}
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
const BuildingType type = m_types[i];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
const int cost = (it != m_costs.end()) ? it->second : 0;
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
}
}
void BuildButtonBar::updateVisibility()
{
// A locked building type's button is hidden until its unlock group is awarded
// (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay
// stable; they simply appear once the type is unlocked.
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
}
// A hidden button leaves the row, so the bar has to take up its new width and
// re-center on it (REQ-UI-BUILD-BAR).
recenter();
}
void BuildButtonBar::recenter()
{
if (m_viewRect.isNull())
{
return;
}
// The layout drops hidden buttons from its size hint, but only once it has been
// re-run: updateVisibility() calls this straight after setVisible(), before Qt
// would get around to it on its own.
layout()->activate();
const QSize barSize = sizeHint();
// Centered, except that a bar wider than the view stays flush with its left edge
// rather than hanging off both sides.
const int x = qMax(m_viewRect.left(),
m_viewRect.left() + (m_viewRect.width() - barSize.width()) / 2);
const int y = m_viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
setGeometry(QRect(QPoint(x, y), barSize));
}
void BuildButtonBar::onBuildButton(int index)
{
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
{
return;
}
const std::size_t idx = static_cast<std::size_t>(index);
if (m_activeIndex == idx)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
return;
}
if (m_activeIndex)
{
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex = idx;
m_buttons[idx]->setChecked(true);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
}
void BuildButtonBar::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
{
clearActiveButton();
}
void BuildButtonBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{
updateAffordability();
}
void BuildButtonBar::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
{
updateVisibility();
updateAffordability();
}
void BuildButtonBar::handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event)
{
m_deconstructButton->setChecked(event->active);
}
void BuildButtonBar::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event)
{
for (std::size_t i = 0; i < m_types.size(); ++i)
{
if (m_types[i] == event->type)
{
// Equivalent to clicking the build button: a disabled (unaffordable) or
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
// hotkey is likewise inert.
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
{
onBuildButton(static_cast<int>(i));
}
return;
}
}
}

View File

@@ -6,6 +6,7 @@
#include <string>
#include <vector>
#include <QRect>
#include <QWidget>
#include "BuilderModeExitedEvent.h"
@@ -21,25 +22,34 @@ class QPushButton;
class Simulation;
class ItemIconCache;
class BuildButtonGrid : public QWidget,
public CombinedEventHandler<BuilderModeExitedEvent,
DeconstructModeChangedEvent,
BuildHotkeyPressedEvent,
BuildingBlocksChangedEvent,
UnlockedBuildingsChangedEvent>
// The build menu: one horizontal row of build buttons floating over the game world
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons; its owner hands it the
// world view's rect through anchorTo() and it centers itself along that rect's
// bottom edge.
class BuildButtonBar : public QWidget,
public CombinedEventHandler<BuilderModeExitedEvent,
DeconstructModeChangedEvent,
BuildHotkeyPressedEvent,
BuildingBlocksChangedEvent,
UnlockedBuildingsChangedEvent>
{
Q_OBJECT
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-ICON), read from disk at runtime like the config files.
// itemIcons is the window-wide per-item icon cache and supplies the
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Not
// owned; must outlive this widget.
BuildButtonGrid(Simulation* sim, const GameConfig* config,
const std::string& iconDir, ItemIconCache* itemIcons,
QWidget* parent = nullptr);
~BuildButtonGrid() override;
BuildButtonBar(Simulation* sim, const GameConfig* config,
const std::string& iconDir, ItemIconCache* itemIcons,
QWidget* parent = nullptr);
~BuildButtonBar() override;
// Centers the bar along the bottom edge of the game world view's rect, given in
// the bar's parent coordinates (REQ-UI-BUILD-BAR). The rect is remembered, so a
// re-center later driven by an unlock needs no second call from the owner.
void anchorTo(const QRect& worldViewRect);
void clearActiveButton();
@@ -53,6 +63,11 @@ private:
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
void updateVisibility();
// Shrinks the bar to its currently shown buttons and re-centers it in the
// anchored rect (REQ-UI-BUILD-BAR). Does nothing until anchorTo() supplied that
// rect, so the construction-time call is harmless.
void recenter();
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
@@ -72,4 +87,5 @@ private:
std::map<BuildingType, int> m_costs;
std::optional<std::size_t> m_activeIndex;
QPushButton* m_deconstructButton;
QRect m_viewRect;
};

View File

@@ -1,419 +0,0 @@
#include "BuildButtonGrid.h"
#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>
#include <QString>
#include <QSvgRenderer>
#include "BuildingType.h"
#include "BuildingTypeSelectedEvent.h"
#include "DeconstructModeToggleRequestedEvent.h"
#include "DisplayName.h"
#include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h"
#include "ItemIconCache.h"
#include "Simulation.h"
namespace
{
// Pixel size the SVG chips are rasterized at; downscaled to the button icon size.
const int kIconRenderSize = 64;
const QSize kIconSize(28, 28);
QPixmap renderChip(const QByteArray& svg)
{
QSvgRenderer renderer(svg);
QPixmap pixmap(kIconRenderSize, kIconRenderSize);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
renderer.render(&painter);
return pixmap;
}
// Builds a build-button icon from a "<id>.svg" chip file. The returned QIcon also
// carries a Disabled-mode pixmap whose chip background is recolored grey, so an
// unaffordable (disabled) button shows the grey variant automatically
// (REQ-UI-BUILD-DISABLED) without any extra work in updateAffordability().
QIcon loadBuildingIcon(const QString& path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
{
return QIcon();
}
const QByteArray svg = file.readAll();
QIcon icon;
icon.addPixmap(renderChip(svg), QIcon::Normal);
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
// rounded background rect; the white glyph uses fill="none" and is left alone.
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\""));
}
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,
ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_iconDir(iconDir)
, m_itemIcons(itemIcons)
{
QGridLayout* layout = new QGridLayout(this);
layout->setSpacing(4);
layout->setContentsMargins(4, 4, 4, 4);
QSignalMapper* mapper = new QSignalMapper(this);
int col = 0;
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)
{
continue;
}
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
// reached through the unified tunnel build mode, not its own button
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-GRID). Both stay player-placeable so
// blueprints and cost totals still account for exits.
if (def.type == BuildingType::TunnelExit)
{
continue;
}
m_types.push_back(def.type);
m_costs[def.type] = def.cost;
const QString name = (def.type == BuildingType::TunnelEntry)
? tr("Tunnel")
: QString::fromStdString(toDisplayName(def.id));
// 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";
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));
}
layout->addWidget(btn, row, col);
const int idx = static_cast<int>(m_buttons.size());
m_buttons.push_back(btn);
mapper->setMapping(btn, idx);
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
++col;
if (col >= kCols)
{
col = 0;
++row;
}
}
connect(mapper, qOverload<int>(&QSignalMapper::mapped), this, &BuildButtonGrid::onBuildButton);
m_deconstructButton = new QPushButton(tr("Deconstruct"), this);
m_deconstructButton->setCheckable(true);
m_deconstructButton->setFixedHeight(48);
m_deconstructButton->setIcon(loadBuildingIcon(
QString::fromStdString(m_iconDir) + "/deconstruct.svg"));
m_deconstructButton->setIconSize(kIconSize);
// Refund tooltip composed from world.refund_percentage (REQ-UI-DECONSTRUCT-BUTTON,
// REQ-BLD-DECONSTRUCT). A finished building refunds the configured percentage; a
// construction site removed before it is built is refunded in full. When the
// percentage is 100% both cases coincide, so the tooltip is simplified to one case.
const int refundPercentage = m_config->world.refundPercentage;
const QString deconstructTooltip = (refundPercentage >= 100)
? tr("Deconstruct buildings. Refunds %1% of the building block cost.")
.arg(refundPercentage)
: tr("Deconstruct buildings. A finished building refunds %1% of its building "
"block cost once removed; a construction site removed before it is built "
"is refunded in full.")
.arg(refundPercentage);
m_deconstructButton->setToolTip(deconstructTooltip);
layout->addWidget(m_deconstructButton, row, col);
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeToggleRequestedEvent>());
});
updateVisibility();
registerForEvents();
}
BuildButtonGrid::~BuildButtonGrid()
{
unregisterForEvents();
}
void BuildButtonGrid::updateAffordability()
{
const int buildingBlocks = m_sim->getBuildingBlocksStock();
// If the currently selected tool can no longer be afforded, exit builder mode
// before recomputing button states so it does not stay selected. Clearing the
// active index first lets the loop below disable the now-unaffordable button.
if (m_activeIndex)
{
const BuildingType activeType = m_types[*m_activeIndex];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
const int cost = (it != m_costs.end()) ? it->second : 0;
if (buildingBlocks < cost)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
}
}
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
const BuildingType type = m_types[i];
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
const int cost = (it != m_costs.end()) ? it->second : 0;
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
}
}
void BuildButtonGrid::updateVisibility()
{
// A locked building type's button is hidden until its unlock group is awarded
// (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay
// stable; they simply appear once the type is unlocked.
for (std::size_t i = 0; i < m_buttons.size(); ++i)
{
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
}
}
void BuildButtonGrid::clearActiveButton()
{
if (m_activeIndex)
{
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex.reset();
}
void BuildButtonGrid::onBuildButton(int index)
{
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
{
return;
}
const std::size_t idx = static_cast<std::size_t>(index);
if (m_activeIndex == idx)
{
clearActiveButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBuilderModeRequestedEvent>());
return;
}
if (m_activeIndex)
{
m_buttons[*m_activeIndex]->setChecked(false);
}
m_activeIndex = idx;
m_buttons[idx]->setChecked(true);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
{
clearActiveButton();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{
updateAffordability();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
{
updateVisibility();
updateAffordability();
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event)
{
m_deconstructButton->setChecked(event->active);
}
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event)
{
for (std::size_t i = 0; i < m_types.size(); ++i)
{
if (m_types[i] == event->type)
{
// Equivalent to clicking the build button: a disabled (unaffordable) or
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
// hotkey is likewise inert.
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
{
onBuildButton(static_cast<int>(i));
}
return;
}
}
}

View File

@@ -10,7 +10,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
@@ -35,7 +35,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp

View File

@@ -332,7 +332,7 @@ void GameWorldView::onFrame()
}
// Unlocked building set changes only after a drop is applied or on Restart
// (REQ-LOCK-BUILDING); the build grid rebuilds its visible buttons.
// (REQ-LOCK-BUILDING); the build bar rebuilds its visible buttons.
int newUnlockedBuildingCount = 0;
for (const BuildingDef& def : m_sim->getConfig().buildings.buildings)
{

View File

@@ -21,13 +21,6 @@
#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;

View File

@@ -14,3 +14,9 @@
// 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);
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
// word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
// REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of
// one is a caller of the other.
const char* const kBlockItemId = "building_block";

View File

@@ -51,6 +51,29 @@ std::optional<BuildingType> buildHotkeyType(int digit, bool shiftHeld)
} // namespace
QString InputMapper::getBuildHotkeyLabel(BuildingType type)
{
// Searched out of the binding table above rather than spelled out a second time,
// so a badge can never claim a key the handler does not act on. The shift glyph
// is written as a code point because the sources are not guaranteed to be read
// as UTF-8 by every compiler this builds with. A plain stroke arrow rather than
// U+21E7 UPWARDS WHITE ARROW: the standard UI fonts do not all carry the outlined
// shift glyph, and the substitute they fall back to is unreadable at badge size.
const QChar shiftGlyph(0x2191); // U+2191 UPWARDS ARROW
for (int digit = 1; digit <= 9; ++digit)
{
if (buildHotkeyType(digit, false) == type)
{
return QString::number(digit);
}
if (buildHotkeyType(digit, true) == type)
{
return shiftGlyph + QString::number(digit);
}
}
return QString();
}
bool InputMapper::handleKeyPress(QKeyEvent* event)
{
// Auto-repeat says nothing new about which keys are down, and a held action is

View File

@@ -1,5 +1,8 @@
#pragma once
#include <QString>
#include "BuildingType.h"
#include "WorldCamera.h"
class QKeyEvent;
@@ -24,6 +27,12 @@ class QKeyEvent;
class InputMapper
{
public:
// The build hotkey that selects the given building type, spelled for display on
// its build button's badge (REQ-UI-BUILD-COST): "1" for a plain digit, "⇧1" for
// a Shift+digit, and an empty string for a building type with no build hotkey.
// Lives here so the badge and the key handling read the same binding table.
static QString getBuildHotkeyLabel(BuildingType type);
// Both return true when the key was consumed; the caller passes anything else
// on to its base class so unrelated shortcuts keep working.
bool handleKeyPress(QKeyEvent* event);

View File

@@ -15,7 +15,7 @@
#include <QVBoxLayout>
#include "BlueprintPanel.h"
#include "BuildButtonGrid.h"
#include "BuildButtonBar.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
@@ -58,36 +58,43 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_itemIcons.get(), m_replay.get(), this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
sideLayout->setContentsMargins(1, 1, 1, 1);
sideLayout->setSpacing(1);
// Building icons live alongside the config (a sibling of the config dir), read
// from disk at runtime the same way visuals.toml is.
const std::string iconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
// Floats over the game world rather than living in the side panel column
// (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
// building it after the world view puts it above the world and its vignettes,
// and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM).
// Its geometry comes from layoutPanels().
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir,
m_itemIcons.get(), this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
sideLayout->setContentsMargins(1, 1, 1, 1);
sideLayout->setSpacing(1);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
// Equal stretch gives the two panels half the column height each
// (REQ-UI-PANEL-COLUMN).
sideLayout->addWidget(m_selectedBuildingPanel, 1);
sideLayout->addWidget(m_buildButtonGrid, 1);
sideLayout->addWidget(m_blueprintPanel, 1);
// Draw a thin border around each of the three side-panel sections. The class
// Draw a thin border around each of the two side-panel sections. The class
// scoped selectors keep the border on the panels themselves rather than
// cascading onto their child widgets; WA_StyledBackground lets the plain
// QWidget subclasses honor the stylesheet box (border/background).
for (QWidget* panel : { static_cast<QWidget*>(m_selectedBuildingPanel),
static_cast<QWidget*>(m_buildButtonGrid),
static_cast<QWidget*>(m_blueprintPanel) })
{
panel->setAttribute(Qt::WA_StyledBackground, true);
}
m_sidePanel->setStyleSheet(QStringLiteral(
"SelectedBuildingPanel, BuildButtonGrid, BlueprintPanel {"
"SelectedBuildingPanel, BlueprintPanel {"
" border: 1px solid palette(mid); }"));
// Created last so it stacks above the other children; covers the whole window and
@@ -164,6 +171,9 @@ void MainWindow::layoutPanels()
m_headerBar->setGeometry(0, 0, mainW, headerH);
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
m_sidePanel->setGeometry(mainW, 0, sideW, totalH);
// Sizes itself to its buttons and centers along the bottom of the world view
// (REQ-UI-BUILD-BAR).
m_buildButtonBar->anchorTo(QRect(0, headerH, mainW, totalH - headerH));
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
}

View File

@@ -27,7 +27,7 @@ class Simulation;
class GameWorldView;
class HeaderBar;
class SelectedBuildingPanel;
class BuildButtonGrid;
class BuildButtonBar;
class BlueprintPanel;
class ItemIconCache;
class QCloseEvent;
@@ -78,12 +78,12 @@ private:
VisualsConfig m_visuals;
Simulation* m_sim;
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
// build grid, world view, and recipe dialog all rasterize the same SVGs.
// build bar, world view, and recipe dialog all rasterize the same SVGs.
std::unique_ptr<ItemIconCache> m_itemIcons;
GameWorldView* m_gameWorldView;
HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel;
BuildButtonGrid* m_buildButtonGrid;
BuildButtonBar* m_buildButtonBar;
BlueprintPanel* m_blueprintPanel;
QWidget* m_sidePanel;
ModalDimOverlay* m_dimOverlay = nullptr;