#include "BuildButtonBar.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "BuildingIconCache.h" #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; // Normal and grey-background chip pixmaps for one icon name. 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(BuildingIconCache& icons, const std::string& iconName) { ChipPixmaps result; result.normal = icons.getChip(iconName, kIconSize.width()); result.grey = icons.getGreyChip(iconName, kIconSize.width()); return result; } // 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(width * dpr), static_cast(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 ".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, BuildingIconCache* buildingIcons, ItemIconCache* itemIcons, QWidget* parent) : QWidget(parent) , m_sim(sim) , m_config(config) , m_buildingIcons(buildingIcons) , 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 selection panel; 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 ButtonFace face = buildButtonFace( loadChipPixmaps(*m_buildingIcons, def.id), 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(m_buttons.size()); m_buttons.push_back(btn); mapper->setMapping(btn, idx); connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map)); } connect(mapper, qOverload(&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(*m_buildingIcons, "deconstruct"), 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()); }); updateVisibility(); registerForEvents(); } BuildButtonBar::~BuildButtonBar() { unregisterForEvents(); } void BuildButtonBar::anchorTo(const QRect& worldViewRect) { m_viewRect = worldViewRect; recenter(); } int BuildButtonBar::getStripHeightPx() const { return height() + kBottomMarginPx; } 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::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 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(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 BuildButtonBar::handleEvent(std::shared_ptr /*event*/) { clearActiveButton(); } void BuildButtonBar::handleEvent(std::shared_ptr /*event*/) { updateAffordability(); } void BuildButtonBar::handleEvent(std::shared_ptr /*event*/) { updateVisibility(); updateAffordability(); } void BuildButtonBar::handleEvent(std::shared_ptr event) { m_deconstructButton->setChecked(event->active); } void BuildButtonBar::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; } } }