float the build buttons as a horizontal bar over the game world and show key bindings inside build buttons
This commit is contained in:
459
src/ui/BuildButtonBar.cpp
Normal file
459
src/ui/BuildButtonBar.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user