Files
dota_factory/src/ui/ShipLayoutDialog.cpp
Malte Langkabel 44ec3c51f1 explain an item wherever the UI names one
Every display naming an item now shows that item's production tooltip, not
only the selection panel's chips: the header block stock, the total cost of
a building multi-selection, the remaining scrap of a debris selection, the
icons of every recipe summary, and a blueprint card's cost.

ItemTooltip moves out of the selection panel and takes an
ItemTooltipContext of its own -- an item is named all over the UI, and the
panel's context carries visuals and a debug flag no tooltip reads. A
recipe line wraps each icon with its amount so the pair can be pointed at
as one statement, and attaches tooltips only where asked: a module button
and the item tooltip itself draw the same line and stay silent.

Hover only wherever the display sits on something the player clicks, whose
click is not free to explain. Note that a recipe line on an option button
can no longer be transparent to the mouse -- Qt never looks inside a
transparent widget for the cursor -- so it relies on press propagation to
keep picking the option.

The header block stock loses world.building_blocks_tooltip, showing what
every other block icon shows instead. The Expand button keeps no tooltip:
its cost is painted into its face with nowhere to hang one, and the button
is due to be removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 21:41:34 +02:00

950 lines
31 KiB
C++

#include "ShipLayoutDialog.h"
#include "ShipStatsPanel.h"
#include <cmath>
#include <functional>
#include "DisplayName.h"
#include "ModalLayer.h"
#include "NameInputDialog.h"
#include "OptionButton.h"
#include "ProductionRules.h"
#include "RecipeLineRow.h"
#include "SectionBox.h"
#include "TooltipTrigger.h"
#include <QGridLayout>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPushButton>
#include <QElapsedTimer>
#include <QScrollArea>
#include <QSignalMapper>
#include <QTimer>
#include <QVBoxLayout>
namespace
{
std::vector<RecipeLineRow::Amount> toAmounts(
const std::vector<RecipeIngredient>& ingredients)
{
std::vector<RecipeLineRow::Amount> amounts;
amounts.reserve(ingredients.size());
for (const RecipeIngredient& ingredient : ingredients)
{
amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount });
}
return amounts;
}
const int kCellSize = 32;
// Empty ship tiles pulse between this grey and white at 1 Hz while a module is
// selected, to draw the player's attention to where modules can be placed.
const double kTwoPi = 6.283185307179586;
const int kPulseIntervalMs = 33;
const int kEmptyCellGrey = 240;
const int kPulseAmplitude = 15;
std::vector<std::string> rotateMaskCW(const std::vector<std::string>& grid)
{
if (grid.empty())
{
return {};
}
const int srcH = static_cast<int>(grid.size());
int srcW = 0;
for (const std::string& row : grid)
{
const int w = static_cast<int>(row.size());
if (w > srcW)
{
srcW = w;
}
}
const int dstW = srcH;
const int dstH = srcW;
std::vector<std::string> dst(dstH, std::string(dstW, 'X'));
for (int row = 0; row < srcH; ++row)
{
for (int col = 0; col < srcW; ++col)
{
const char ch = (col < static_cast<int>(grid[row].size()))
? grid[row][col]
: 'X';
dst[col][srcH - 1 - row] = ch;
}
}
return dst;
}
} // namespace
// ---------------------------------------------------------------------------
// Grid rendering widget (nested inside dialog)
// ---------------------------------------------------------------------------
class LayoutGridWidget : public QWidget
{
public:
LayoutGridWidget(ShipLayoutDialog* dialog, QWidget* parent = nullptr)
: QWidget(parent)
, m_dialog(dialog)
{
setMouseTracking(true);
m_pulseClock.start();
m_pulseTimer = new QTimer(this);
m_pulseTimer->setInterval(kPulseIntervalMs);
connect(m_pulseTimer, &QTimer::timeout, this, [this]() { update(); });
}
void setGridData(const std::vector<std::vector<ShipLayoutDialog::CellInfo>>* grid,
int rows, int cols,
const std::vector<PlacedModule>* placed,
const GameConfig* config)
{
m_grid = grid;
m_rows = rows;
m_cols = cols;
m_placed = placed;
m_config = config;
setFixedSize(cols * kCellSize + 1, rows * kCellSize + 1);
}
void setGhostData(std::optional<int> moduleIndex, Rotation rotation)
{
m_ghostModuleIdx = moduleIndex;
m_ghostRotation = rotation;
// Only animate the empty-tile pulse while a module is selected.
if (moduleIndex.has_value())
{
if (!m_pulseTimer->isActive())
{
m_pulseClock.restart();
m_pulseTimer->start();
}
}
else
{
m_pulseTimer->stop();
}
}
protected:
void paintEvent(QPaintEvent* /*event*/) override
{
if (!m_grid || m_rows == 0 || m_cols == 0)
{
return;
}
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, false);
// While a module is selected, pulse empty tiles at 1 Hz around the base
// grey (kEmptyCellGrey +/- kPulseAmplitude). All empty tiles share the
// same phase, so compute it once.
QColor emptyCellColor(kEmptyCellGrey, kEmptyCellGrey, kEmptyCellGrey);
if (m_ghostModuleIdx.has_value())
{
const double seconds = m_pulseClock.elapsed() / 1000.0;
const double oscillation = std::sin(seconds * kTwoPi);
const int channel = kEmptyCellGrey
+ static_cast<int>(std::lround(oscillation * kPulseAmplitude));
emptyCellColor = QColor(channel, channel, channel);
}
for (int r = 0; r < m_rows; ++r)
{
for (int c = 0; c < m_cols; ++c)
{
const QRect cellRect(c * kCellSize, r * kCellSize, kCellSize, kCellSize);
const ShipLayoutDialog::CellInfo& cell = (*m_grid)[r][c];
if (!cell.buildable)
{
painter.fillRect(cellRect, QColor(30, 30, 30));
}
else if (cell.moduleIndex.has_value())
{
const PlacedModule& pm = (*m_placed)[*cell.moduleIndex];
const ModuleDef* def = findModule(pm.moduleId);
QColor color(Qt::gray);
QString glyph;
if (def)
{
color = QColor(QString::fromStdString(def->fillColor));
glyph = QString::fromStdString(def->glyph);
}
painter.fillRect(cellRect, color);
if (!glyph.isEmpty())
{
painter.setPen(Qt::white);
painter.drawText(cellRect, Qt::AlignCenter, glyph);
}
}
else
{
painter.fillRect(cellRect, emptyCellColor);
}
painter.setPen(QColor(100, 100, 100));
painter.drawRect(cellRect);
}
}
// Draw ghost
if (m_ghostModuleIdx.has_value() && m_hoverCell.x() >= 0 && m_config)
{
const ModuleDef& def = m_config->modules.modules[*m_ghostModuleIdx];
const std::vector<std::string> mask = rotateMask(def.surfaceMask, m_ghostRotation);
QColor ghostColor(QString::fromStdString(def.fillColor));
ghostColor.setAlpha(100);
for (int mr = 0; mr < static_cast<int>(mask.size()); ++mr)
{
for (int mc = 0; mc < static_cast<int>(mask[mr].size()); ++mc)
{
if (mask[mr][mc] != 'O')
{
continue;
}
const int gr = m_hoverCell.y() + mr;
const int gc = m_hoverCell.x() + mc;
if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols)
{
const QRect cellRect(gc * kCellSize, gr * kCellSize,
kCellSize, kCellSize);
painter.fillRect(cellRect, ghostColor);
}
}
}
}
}
void mouseMoveEvent(QMouseEvent* event) override
{
const QPoint pos = event->pos();
const QPoint cell(pos.x() / kCellSize, pos.y() / kCellSize);
if (cell != m_hoverCell)
{
m_hoverCell = cell;
update();
}
}
void mousePressEvent(QMouseEvent* event) override
{
if (event->button() != Qt::LeftButton)
{
return;
}
const QPoint pos = event->pos();
const QPoint cell(pos.x() / kCellSize, pos.y() / kCellSize);
emit m_dialog->gridCellClicked(cell);
}
void leaveEvent(QEvent* /*event*/) override
{
m_hoverCell = QPoint(-1, -1);
update();
}
private:
const ModuleDef* findModule(const std::string& id) const
{
if (!m_config)
{
return nullptr;
}
return m_config->modules.findModuleDef(id);
}
std::vector<std::string> rotateMask(const std::vector<std::string>& mask,
Rotation rotation) const
{
int steps = 0;
switch (rotation)
{
case Rotation::East: steps = 0; break;
case Rotation::South: steps = 1; break;
case Rotation::West: steps = 2; break;
case Rotation::North: steps = 3; break;
}
std::vector<std::string> result = mask;
for (int i = 0; i < steps; ++i)
{
result = rotateMaskCW(result);
}
return result;
}
ShipLayoutDialog* m_dialog;
const std::vector<std::vector<ShipLayoutDialog::CellInfo>>* m_grid = nullptr;
int m_rows = 0;
int m_cols = 0;
const std::vector<PlacedModule>* m_placed = nullptr;
const GameConfig* m_config = nullptr;
std::optional<int> m_ghostModuleIdx;
Rotation m_ghostRotation = Rotation::East;
QPoint m_hoverCell = QPoint(-1, -1);
QTimer* m_pulseTimer = nullptr;
QElapsedTimer m_pulseClock;
};
// ---------------------------------------------------------------------------
// Blueprint panel (third column of the layout dialog)
// ---------------------------------------------------------------------------
class ShipLayoutBlueprintPanel : public QWidget
{
public:
ShipLayoutBlueprintPanel(
std::vector<ShipLayoutBlueprint>& allBlueprints,
const std::string& shipType,
std::function<std::vector<PlacedModule>()> getModules,
std::function<void(const std::vector<PlacedModule>&)> loadModules,
QWidget* parent = nullptr)
: QWidget(parent)
, m_allBlueprints(allBlueprints)
, m_shipType(shipType)
, m_getModules(std::move(getModules))
, m_loadModules(std::move(loadModules))
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
QPushButton* createBtn = new QPushButton(tr("Create Blueprint"), this);
createBtn->setFixedHeight(36);
layout->addWidget(createBtn);
m_scrollArea = new QScrollArea(this);
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_scrollContainer = new QWidget(m_scrollArea);
m_listLayout = new QVBoxLayout(m_scrollContainer);
m_listLayout->setContentsMargins(0, 0, 0, 0);
m_listLayout->setSpacing(2);
m_listLayout->addStretch();
m_scrollArea->setWidget(m_scrollContainer);
layout->addWidget(m_scrollArea, 1);
connect(createBtn, &QPushButton::clicked, this, [this]() {
// Opened on the same layer as the dialog this panel sits in, so it stacks
// over it on the one dim (REQ-MOD-UI-BLUEPRINT-CREATE, REQ-UI-MODAL-DIM).
ModalLayer* layer = ModalLayer::findFor(*this);
if (layer == nullptr) { return; }
NameInputDialog nameDialog(tr("Create Blueprint"), tr("Blueprint name:"));
if (layer->execute(nameDialog) != QDialog::Accepted) { return; }
const QString name = nameDialog.getName();
if (name.isEmpty()) { return; }
ShipLayoutBlueprint bp;
bp.name = name;
bp.shipType = m_shipType;
bp.modules = m_getModules();
m_allBlueprints.push_back(std::move(bp));
rebuildList();
});
rebuildList();
}
private:
void rebuildList()
{
// Remove all items except the trailing stretch.
while (m_listLayout->count() > 1)
{
QLayoutItem* item = m_listLayout->takeAt(0);
if (item->widget()) { delete item->widget(); }
delete item;
}
for (std::size_t i = 0; i < m_allBlueprints.size(); ++i)
{
const ShipLayoutBlueprint& bp = m_allBlueprints[i];
if (bp.shipType != m_shipType) { continue; }
QWidget* row = new QWidget(m_scrollContainer);
QHBoxLayout* rowLayout = new QHBoxLayout(row);
rowLayout->setContentsMargins(0, 0, 0, 0);
rowLayout->setSpacing(2);
QPushButton* nameBtn = new QPushButton(bp.name, row);
nameBtn->setFixedHeight(36);
QPushButton* delBtn = new QPushButton("\xc3\x97", row);
delBtn->setFixedWidth(28);
delBtn->setFixedHeight(36);
rowLayout->addWidget(nameBtn, 1);
rowLayout->addWidget(delBtn, 0);
m_listLayout->insertWidget(m_listLayout->count() - 1, row);
const std::vector<PlacedModule> modules = bp.modules;
connect(nameBtn, &QPushButton::clicked, this, [this, modules]() {
m_loadModules(modules);
});
connect(delBtn, &QPushButton::clicked, this, [this, i]() {
m_allBlueprints.erase(m_allBlueprints.begin()
+ static_cast<std::ptrdiff_t>(i));
rebuildList();
});
}
}
std::vector<ShipLayoutBlueprint>& m_allBlueprints;
std::string m_shipType;
std::function<std::vector<PlacedModule>()> m_getModules;
std::function<void(const std::vector<PlacedModule>&)> m_loadModules;
QScrollArea* m_scrollArea = nullptr;
QWidget* m_scrollContainer = nullptr;
QVBoxLayout* m_listLayout = nullptr;
};
// ---------------------------------------------------------------------------
// ShipLayoutDialog implementation
// ---------------------------------------------------------------------------
ShipLayoutDialog::ShipLayoutDialog(const std::string& shipId,
const ShipLayoutConfig& currentLayout,
std::vector<ShipLayoutBlueprint>& allBlueprints,
std::set<std::string> unlockedModuleIds,
bool debugDraw,
const ItemTooltipContext& context,
QWidget* parent)
: ModalDialog(parent)
, m_context(context)
, m_config(context.config)
, m_itemIcons(context.itemIcons)
, m_shipId(shipId)
, m_unlockedModuleIds(std::move(unlockedModuleIds))
, m_rows(0)
, m_cols(0)
, m_placedModules(currentLayout.placedModules)
, m_currentRotation(Rotation::East)
, m_removeButton(nullptr)
, m_gridWidget(nullptr)
, m_statsPanel(nullptr)
, m_debugDraw(debugDraw)
{
// Find the ship's layout grid.
const ShipDef* shipDef = m_config->ships.findShipDef(shipId);
if (shipDef)
{
m_shipLayout = shipDef->layout;
}
m_rows = static_cast<int>(m_shipLayout.size());
m_cols = 0;
for (const std::string& row : m_shipLayout)
{
const int w = static_cast<int>(row.size());
if (w > m_cols)
{
m_cols = w;
}
}
// Initialize grid.
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, std::nullopt}));
for (int r = 0; r < m_rows; ++r)
{
for (int c = 0; c < static_cast<int>(m_shipLayout[r].size()); ++c)
{
if (m_shipLayout[r][c] == 'O')
{
m_grid[r][c].buildable = true;
}
}
}
rebuildOccupancy();
// --- UI layout ---
QVBoxLayout* outerLayout = new QVBoxLayout(this);
// The dialog's own title line; it carries no close button, having Confirm and Cancel
// of its own at the bottom (REQ-UI-MODAL-CHROME, REQ-MOD-UI-DIALOG).
addHeader(outerLayout, tr("Configure Ship Layout"), false);
// Top: grid widget.
LayoutGridWidget* gridW = new LayoutGridWidget(this, this);
gridW->setGridData(&m_grid, m_rows, m_cols, &m_placedModules, m_config);
gridW->setGhostData(m_activeModuleIndex, m_currentRotation);
m_gridWidget = gridW;
outerLayout->addWidget(m_gridWidget, 0, Qt::AlignHCenter | Qt::AlignTop);
// Middle: three-column area (stats | module buttons | blueprints).
QHBoxLayout* columnsLayout = new QHBoxLayout();
// Left column: ship stats panel, and beneath it what the layout being configured
// would cost to build (REQ-MOD-UI-DIALOG). The cost sits outside the stats panel
// because that widget is shared with the live ship card and the balancing tool,
// neither of which costs anything -- and the balancing tool is deliberately built
// without the item icons this line draws with.
QVBoxLayout* leftLayout = new QVBoxLayout();
m_statsPanel = new ShipStatsPanel(m_config, this);
m_statsPanel->setDebugDrawEnabled(m_debugDraw);
leftLayout->addWidget(m_statsPanel);
m_buildCostSection = new SectionBox(tr("Build cost"), this);
m_buildCostLine = new RecipeLineRow(m_itemIcons, nullptr, m_buildCostSection);
// A section of the dialog rather than a control, so its materials explain themselves
// on hover or click, as they do in the panel (REQ-UI-ITEM-VALUE-TOOLTIP).
m_buildCostLine->setItemTooltips(m_context, TooltipTrigger::Trigger::HoverAndClick);
m_buildCostSection->getContentLayout()->addWidget(m_buildCostLine);
leftLayout->addWidget(m_buildCostSection);
leftLayout->addStretch(1);
columnsLayout->addLayout(leftLayout);
// Center column: module selection buttons.
QVBoxLayout* centerLayout = new QVBoxLayout();
QGridLayout* buttonGrid = new QGridLayout();
buttonGrid->setSpacing(4);
QSignalMapper* mapper = new QSignalMapper(this);
int col = 0;
int row = 0;
const int kCols = 2;
for (int i = 0; i < static_cast<int>(m_config->modules.modules.size()); ++i)
{
const ModuleDef& def = m_config->modules.modules[i];
if (m_unlockedModuleIds.count(def.id) == 0)
{
m_moduleButtons.push_back(nullptr);
continue;
}
// The module's name over what it costs -- its materials and the production time
// it adds (REQ-MOD-UI-DIALOG). The glyph is how a module is identified on the
// layout grid, not here. Both children are transparent to the mouse so a click
// anywhere on the face still reaches the button.
OptionButton* btn = new OptionButton(this);
btn->setCheckable(true);
QVBoxLayout* face = new QVBoxLayout(btn);
face->setContentsMargins(8, 4, 8, 4);
face->setSpacing(1);
QLabel* nameLabel =
new QLabel(QString::fromStdString(toDisplayName(def.id)), btn);
nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true);
face->addWidget(nameLabel);
RecipeLineRow::Spec cost;
cost.inputs = toAmounts(def.materials);
cost.durationSeconds = def.productionTimeSeconds;
cost.durationIsAddition = true;
RecipeLineRow* costLine = new RecipeLineRow(m_itemIcons, nullptr, btn);
costLine->setAttribute(Qt::WA_TransparentForMouseEvents, true);
face->addWidget(costLine);
costLine->setLine(cost);
// The config tooltip stays: it says what the module does, which the cost line
// does not (REQ-MOD-UI-MODULE-TOOLTIP). Hover only: the click selects the module
// (REQ-UI-TOOLTIP-TRIGGER). The materials on the face stay silent whether or not
// this module fills its description in -- the button is a described control, and
// one module's config gap must not make it read differently from the one beside
// it (REQ-UI-ITEM-VALUE-TOOLTIP).
if (def.tooltip)
{
TooltipTrigger::attachText(*btn, QString::fromStdString(*def.tooltip),
TooltipTrigger::Trigger::HoverOnly);
}
buttonGrid->addWidget(btn, row, col);
m_moduleButtons.push_back(btn);
mapper->setMapping(btn, i);
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
++col;
if (col >= kCols)
{
col = 0;
++row;
}
}
connect(mapper, qOverload<int>(&QSignalMapper::mapped),
this, &ShipLayoutDialog::onModuleButtonClicked);
// Remove button.
m_removeButton = new QPushButton(tr("Remove"), this);
m_removeButton->setCheckable(true);
m_removeButton->setFixedHeight(48);
if (col > 0)
{
++row;
}
buttonGrid->addWidget(m_removeButton, row, 0, 1, kCols);
connect(m_removeButton, &QPushButton::clicked,
this, &ShipLayoutDialog::onRemoveButtonClicked);
centerLayout->addLayout(buttonGrid);
centerLayout->addStretch();
columnsLayout->addLayout(centerLayout);
// Right column: blueprint panel.
ShipLayoutBlueprintPanel* bpPanel = new ShipLayoutBlueprintPanel(
allBlueprints,
m_shipId,
[this]() { return m_placedModules; },
[this](const std::vector<PlacedModule>& mods) { loadLayoutBlueprint(mods); },
this);
columnsLayout->addWidget(bpPanel);
outerLayout->addLayout(columnsLayout, 1);
// Bottom: confirm / cancel buttons.
QHBoxLayout* bottomBar = new QHBoxLayout();
QPushButton* confirmBtn = new QPushButton(tr("Confirm"), this);
QPushButton* cancelBtn = new QPushButton(tr("Cancel"), this);
bottomBar->addWidget(confirmBtn);
bottomBar->addWidget(cancelBtn);
outerLayout->addLayout(bottomBar);
connect(confirmBtn, &QPushButton::clicked, this, &ShipLayoutDialog::onConfirm);
connect(cancelBtn, &QPushButton::clicked, this, &ShipLayoutDialog::onCancel);
// Initial stats display.
updateStats();
// Grid click handler.
connect(this, &ShipLayoutDialog::gridCellClicked, this, [this](QPoint cell) {
if (!m_removeMode && !m_activeModuleIndex.has_value())
{
return;
}
if (m_removeMode)
{
// Remove mode: find and remove module at cell.
if (cell.y() >= 0 && cell.y() < m_rows && cell.x() >= 0 && cell.x() < m_cols)
{
const std::optional<int> idx = m_grid[cell.y()][cell.x()].moduleIndex;
if (idx.has_value())
{
m_placedModules.erase(m_placedModules.begin() + *idx);
rebuildOccupancy();
updateGridWidget();
updateStats();
}
}
return;
}
// Place module.
const ModuleDef& def = m_config->modules.modules[*m_activeModuleIndex];
if (canPlaceModule(def, cell, m_currentRotation))
{
PlacedModule pm;
pm.moduleId = def.id;
pm.position = cell;
pm.rotation = m_currentRotation;
m_placedModules.push_back(pm);
rebuildOccupancy();
updateGridWidget();
updateStats();
}
});
}
std::optional<ShipLayoutConfig> ShipLayoutDialog::getResult() const
{
return m_result;
}
void ShipLayoutDialog::keyPressEvent(QKeyEvent* event)
{
if (event->key() == Qt::Key_R)
{
if (event->modifiers() & Qt::ShiftModifier)
{
// Shift+R: rotate CW.
switch (m_currentRotation)
{
case Rotation::East: m_currentRotation = Rotation::South; break;
case Rotation::South: m_currentRotation = Rotation::West; break;
case Rotation::West: m_currentRotation = Rotation::North; break;
case Rotation::North: m_currentRotation = Rotation::East; break;
}
}
else
{
// R: rotate CCW.
switch (m_currentRotation)
{
case Rotation::East: m_currentRotation = Rotation::North; break;
case Rotation::North: m_currentRotation = Rotation::West; break;
case Rotation::West: m_currentRotation = Rotation::South; break;
case Rotation::South: m_currentRotation = Rotation::East; break;
}
}
updateGridWidget();
}
else
{
// Q among them, which the base turns into requestDismiss().
ModalDialog::keyPressEvent(event);
}
}
bool ShipLayoutDialog::isDismissible() const
{
return true;
}
void ShipLayoutDialog::requestDismiss()
{
// Both mode exits go through the handler the buttons use, so a dismissal and a
// button can never leave different state behind (REQ-MOD-PLACEMENT, REQ-MOD-REMOVE).
if (m_activeModuleIndex.has_value()) { onModuleButtonClicked(*m_activeModuleIndex); }
else if (m_removeMode) { onRemoveButtonClicked(); }
else { onCancel(); }
}
void ShipLayoutDialog::onModuleButtonClicked(int index)
{
if (m_activeModuleIndex == index)
{
if (m_moduleButtons[index]) { m_moduleButtons[index]->setChecked(false); }
m_activeModuleIndex = std::nullopt;
}
else
{
for (int i = 0; i < static_cast<int>(m_moduleButtons.size()); ++i)
{
if (m_moduleButtons[i]) { m_moduleButtons[i]->setChecked(i == index); }
}
m_removeButton->setChecked(false);
m_removeMode = false;
m_activeModuleIndex = index;
}
updateGridWidget();
}
void ShipLayoutDialog::onRemoveButtonClicked()
{
if (m_removeMode)
{
m_removeMode = false;
m_removeButton->setChecked(false);
}
else
{
for (QPushButton* btn : m_moduleButtons)
{
if (btn) { btn->setChecked(false); }
}
m_activeModuleIndex = std::nullopt;
m_removeMode = true;
m_removeButton->setChecked(true);
}
updateGridWidget();
}
void ShipLayoutDialog::onConfirm()
{
ShipLayoutConfig layout;
layout.placedModules = m_placedModules;
m_result = layout;
accept();
}
void ShipLayoutDialog::onCancel()
{
m_result = std::nullopt;
reject();
}
void ShipLayoutDialog::rebuildOccupancy()
{
for (int r = 0; r < m_rows; ++r)
{
for (int c = 0; c < m_cols; ++c)
{
m_grid[r][c].moduleIndex = std::nullopt;
}
}
for (int i = 0; i < static_cast<int>(m_placedModules.size()); ++i)
{
const PlacedModule& pm = m_placedModules[i];
const ModuleDef* def = m_config->modules.findModuleDef(pm.moduleId);
if (!def)
{
continue;
}
const std::vector<std::string> mask = rotatedMask(*def, pm.rotation);
for (int mr = 0; mr < static_cast<int>(mask.size()); ++mr)
{
for (int mc = 0; mc < static_cast<int>(mask[mr].size()); ++mc)
{
if (mask[mr][mc] != 'O')
{
continue;
}
const int gr = pm.position.y() + mr;
const int gc = pm.position.x() + mc;
if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols)
{
m_grid[gr][gc].moduleIndex = i;
}
}
}
}
}
void ShipLayoutDialog::updateGridWidget()
{
LayoutGridWidget* gridW = static_cast<LayoutGridWidget*>(m_gridWidget);
gridW->setGhostData(m_activeModuleIndex, m_currentRotation);
gridW->update();
}
void ShipLayoutDialog::updateStats()
{
m_statsPanel->refresh(m_shipId, m_placedModules);
// What the layout as it currently stands would cost to build, so the price follows
// every placement and removal (REQ-MOD-UI-DIALOG). The sums come from the
// simulation's own rules rather than being added up a second time here
// (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME).
ShipLayoutConfig layout;
layout.placedModules = m_placedModules;
const std::map<std::string, int> materials =
computeShipyardRequiredMaterials(*m_config, m_shipId, layout);
RecipeLineRow::Spec cost;
cost.inputs.reserve(materials.size());
for (const std::pair<const std::string, int>& entry : materials)
{
cost.inputs.push_back(RecipeLineRow::Amount{ entry.first, entry.second });
}
cost.durationSeconds =
computeShipyardProductionTimeSeconds(*m_config, m_shipId, layout);
m_buildCostLine->setLine(cost);
// A schematic costing no materials at all has no line to draw, and the section then
// stays out of the way.
m_buildCostSection->setVisible(!cost.inputs.empty());
}
bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,
Rotation rotation) const
{
const std::vector<std::string> mask = rotatedMask(def, rotation);
for (int mr = 0; mr < static_cast<int>(mask.size()); ++mr)
{
for (int mc = 0; mc < static_cast<int>(mask[mr].size()); ++mc)
{
if (mask[mr][mc] != 'O')
{
continue;
}
const int gr = position.y() + mr;
const int gc = position.x() + mc;
if (gr < 0 || gr >= m_rows || gc < 0 || gc >= m_cols)
{
return false;
}
if (!m_grid[gr][gc].buildable)
{
return false;
}
if (m_grid[gr][gc].moduleIndex.has_value())
{
return false;
}
}
}
return true;
}
std::vector<std::string> ShipLayoutDialog::rotatedMask(const ModuleDef& def,
Rotation rotation) const
{
int steps = 0;
switch (rotation)
{
case Rotation::East: steps = 0; break;
case Rotation::South: steps = 1; break;
case Rotation::West: steps = 2; break;
case Rotation::North: steps = 3; break;
}
std::vector<std::string> result = def.surfaceMask;
for (int i = 0; i < steps; ++i)
{
result = rotateMaskCW(result);
}
return result;
}
void ShipLayoutDialog::loadLayoutBlueprint(const std::vector<PlacedModule>& modules)
{
m_placedModules.clear();
// Build a temporary occupancy grid to detect overlaps within the blueprint.
std::vector<std::vector<bool>> occupied(m_rows, std::vector<bool>(m_cols, false));
for (const PlacedModule& pm : modules)
{
// Validate module type exists and is unlocked.
const ModuleDef* def = m_config->modules.findModuleDef(pm.moduleId);
if (!def || m_unlockedModuleIds.count(def->id) == 0) { continue; }
const std::vector<std::string> mask = rotatedMask(*def, pm.rotation);
bool valid = true;
for (int mr = 0; mr < static_cast<int>(mask.size()) && valid; ++mr)
{
for (int mc = 0; mc < static_cast<int>(mask[mr].size()) && valid; ++mc)
{
if (mask[mr][mc] != 'O') { continue; }
const int gr = pm.position.y() + mr;
const int gc = pm.position.x() + mc;
if (gr < 0 || gr >= m_rows || gc < 0 || gc >= m_cols) { valid = false; break; }
if (!m_grid[gr][gc].buildable) { valid = false; break; }
if (occupied[gr][gc]) { valid = false; break; }
}
}
if (!valid) { continue; }
// Mark cells as occupied.
for (int mr = 0; mr < static_cast<int>(mask.size()); ++mr)
{
for (int mc = 0; mc < static_cast<int>(mask[mr].size()); ++mc)
{
if (mask[mr][mc] != 'O') { continue; }
const int gr = pm.position.y() + mr;
const int gc = pm.position.x() + mc;
if (gr >= 0 && gr < m_rows && gc >= 0 && gc < m_cols)
{
occupied[gr][gc] = true;
}
}
}
m_placedModules.push_back(pm);
}
rebuildOccupancy();
updateGridWidget();
updateStats();
}