#include "BuildButtonGrid.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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 ".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 ".svg" file, using the same // recolor rule as loadBuildingIcon. Empty pixmaps if the file cannot be read. struct ChipPixmaps { QPixmap normal; QPixmap grey; }; ChipPixmaps loadChipPixmaps(const QString& path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) { return {}; } const QByteArray svg = file.readAll(); ChipPixmaps result; result.normal = renderChip(svg); QString greyed = QString::fromUtf8(svg); static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\"")); const QRegularExpressionMatch match = fillPattern.match(greyed); if (match.hasMatch()) { greyed.replace(match.capturedStart(), match.capturedLength(), QStringLiteral("fill=\"#5f636e\"")); } result.grey = renderChip(greyed.toUtf8()); return result; } // A build button that paints its own face — chip icon at the left, the building // name above the cost, and the building_block item icon after the cost number in // place of the "Blocks" word (REQ-UI-BUILD-COST, REQ-UI-BUILD-ICON). Custom paint // (rather than the native icon+text) is needed because a QPushButton holds only // one icon; this stays adaptive to the button width and greys itself when the // button is disabled/unaffordable (REQ-UI-BUILD-DISABLED). class BuildButton : public QPushButton { public: BuildButton(const ChipPixmaps& chip, const QString& name, const QString& costText, const QPixmap& blockIcon, QWidget* parent) : QPushButton(parent) , m_chip(chip) , m_name(name) , m_costText(costText) , m_blockIcon(blockIcon) { } protected: void paintEvent(QPaintEvent* event) override { QPushButton::paintEvent(event); // frame, checked/hover state QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::SmoothPixmapTransform, true); const bool on = isEnabled(); const QRect area = rect().adjusted(6, 4, -6, -4); const int chipSize = kIconSize.width(); const QPixmap& chip = on ? m_chip.normal : m_chip.grey; if (!chip.isNull()) { painter.drawPixmap( QRect(area.x(), area.y() + (area.height() - chipSize) / 2, chipSize, chipSize), chip); } const QRect textArea(area.x() + chipSize + 6, area.y(), area.width() - chipSize - 6, area.height()); const QFontMetrics metrics(font()); const int lineHeight = metrics.height(); painter.setFont(font()); painter.setPen(palette().color(on ? QPalette::Active : QPalette::Disabled, QPalette::ButtonText)); // Name fills everything above the bottom cost line (word-wrapped). painter.drawText( QRect(textArea.x(), textArea.y(), textArea.width(), textArea.height() - lineHeight), Qt::AlignLeft | Qt::AlignVCenter | Qt::TextWordWrap, m_name); // Cost line: "" then the block icon (or " Blocks" when no icon). const int costY = textArea.bottom() - lineHeight + 1; if (m_blockIcon.isNull()) { painter.drawText( QRect(textArea.x(), costY, textArea.width(), lineHeight), Qt::AlignLeft | Qt::AlignVCenter, QObject::tr("%1 Blocks").arg(m_costText)); return; } const int costWidth = metrics.horizontalAdvance(m_costText); painter.drawText(QRect(textArea.x(), costY, costWidth, lineHeight), Qt::AlignLeft | Qt::AlignVCenter, m_costText); const qreal iconDpr = m_blockIcon.devicePixelRatio(); const int iconW = static_cast(m_blockIcon.width() / iconDpr); const int iconH = static_cast(m_blockIcon.height() / iconDpr); if (!on) { painter.setOpacity(0.45); } painter.drawPixmap( QRect(textArea.x() + costWidth + 4, costY + (lineHeight - iconH) / 2, iconW, iconH), m_blockIcon); } private: ChipPixmaps m_chip; QString m_name; QString m_costText; QPixmap m_blockIcon; }; } BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config, const std::string& iconDir, 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(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(&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()); }); 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::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()); } } for (std::size_t i = 0; i < m_buttons.size(); ++i) { const BuildingType type = m_types[i]; const std::map::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(m_buttons.size())) { return; } const std::size_t idx = static_cast(index); if (m_activeIndex == idx) { clearActiveButton(); EventManager::getInstance()->sendEventImmediately( std::make_shared()); return; } if (m_activeIndex) { m_buttons[*m_activeIndex]->setChecked(false); } m_activeIndex = idx; m_buttons[idx]->setChecked(true); EventManager::getInstance()->sendEventImmediately( std::make_shared(m_types[idx])); } void BuildButtonGrid::handleEvent(std::shared_ptr /*event*/) { clearActiveButton(); } void BuildButtonGrid::handleEvent(std::shared_ptr /*event*/) { updateAffordability(); } void BuildButtonGrid::handleEvent(std::shared_ptr /*event*/) { updateVisibility(); updateAffordability(); } void BuildButtonGrid::handleEvent(std::shared_ptr event) { m_deconstructButton->setChecked(event->active); } void BuildButtonGrid::handleEvent(std::shared_ptr 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(i)); } return; } } }