QPushButton derives its size hint from its text and icon and ignores a layout set on it, so the recipe options, the shipyard schematic options and the layout dialog's module buttons all measured themselves as empty and clipped the name and recipe line drawn on them. OptionButton takes the hint from its layout instead and lets the style add what the button frame needs, so the padding stays the platform's rather than a number picked here. It falls back to QPushButton's own hint when there is no layout, which is what the "(None)" option uses. The module buttons drop their fixed height with it and take what their cost line needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
916 lines
30 KiB
C++
916 lines
30 KiB
C++
#include "ShipLayoutDialog.h"
|
|
#include "ShipStatsPanel.h"
|
|
|
|
#include <cmath>
|
|
#include <functional>
|
|
|
|
#include "DisplayName.h"
|
|
#include "OptionButton.h"
|
|
#include "ProductionRules.h"
|
|
#include "RecipeLineRow.h"
|
|
#include "SectionBox.h"
|
|
|
|
#include <QGridLayout>
|
|
#include <QHBoxLayout>
|
|
#include <QInputDialog>
|
|
#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]() {
|
|
bool ok = false;
|
|
const QString name = QInputDialog::getText(
|
|
this, tr("Create Blueprint"), tr("Blueprint name:"),
|
|
QLineEdit::Normal, QString(), &ok);
|
|
if (!ok || name.trimmed().isEmpty()) { return; }
|
|
|
|
ShipLayoutBlueprint bp;
|
|
bp.name = name.trimmed();
|
|
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 GameConfig* config,
|
|
const std::string& shipId,
|
|
const ShipLayoutConfig& currentLayout,
|
|
std::vector<ShipLayoutBlueprint>& allBlueprints,
|
|
std::set<std::string> unlockedModuleIds,
|
|
bool debugDraw,
|
|
ItemIconCache* itemIcons,
|
|
QWidget* parent)
|
|
: QDialog(parent)
|
|
, m_config(config)
|
|
, m_itemIcons(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)
|
|
{
|
|
setWindowTitle(tr("Configure Ship Layout"));
|
|
setModal(true);
|
|
|
|
// Find the ship's layout grid.
|
|
const ShipDef* shipDef = 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);
|
|
|
|
// 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(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);
|
|
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>(config->modules.modules.size()); ++i)
|
|
{
|
|
const ModuleDef& def = config->modules.modules[i];
|
|
if (m_unlockedModuleIds.count(def.id) == 0)
|
|
{
|
|
m_moduleButtons.push_back(nullptr);
|
|
continue;
|
|
}
|
|
// The module's name and glyph over what it costs -- its materials and the
|
|
// production time it adds (REQ-MOD-UI-DIALOG). 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))
|
|
+ " " + QString::fromStdString(def.glyph), 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).
|
|
if (def.tooltip)
|
|
{
|
|
btn->setToolTip(QString::fromStdString(*def.tooltip));
|
|
}
|
|
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, [this]() {
|
|
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();
|
|
});
|
|
|
|
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
|
|
{
|
|
QDialog::keyPressEvent(event);
|
|
}
|
|
}
|
|
|
|
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::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();
|
|
}
|