implement blueprints

This commit is contained in:
2026-04-26 21:56:49 +02:00
parent 4605c2e443
commit 71677b806a
8 changed files with 453 additions and 3 deletions

22
src/ui/Blueprint.h Normal file
View File

@@ -0,0 +1,22 @@
#pragma once
#include <vector>
#include <QPoint>
#include <QString>
#include "BuildingType.h"
#include "Rotation.h"
struct BlueprintBuilding
{
BuildingType type;
Rotation rotation;
QPoint offset; // tile offset from bounding-box center (floor for even sizes)
};
struct Blueprint
{
QString name;
std::vector<BlueprintBuilding> buildings;
};

247
src/ui/BlueprintPanel.cpp Normal file
View File

@@ -0,0 +1,247 @@
#include "BlueprintPanel.h"
#include <algorithm>
#include <climits>
#include <QInputDialog>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
#include "Building.h"
#include "BuildingSystem.h"
#include "Simulation.h"
BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_currentBlocks(0)
, m_deleteMode(false)
, m_activeIndex(-1)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
m_createBtn = new QPushButton("Create Blueprint", this);
m_createBtn->setFixedHeight(48);
m_createBtn->setEnabled(false);
layout->addWidget(m_createBtn);
m_deleteBtn = new QPushButton("Delete Blueprint", this);
m_deleteBtn->setFixedHeight(48);
m_deleteBtn->setCheckable(true);
layout->addWidget(m_deleteBtn);
QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_buttonsContainer = new QWidget(scrollArea);
m_buttonsLayout = new QVBoxLayout(m_buttonsContainer);
m_buttonsLayout->setContentsMargins(0, 0, 0, 0);
m_buttonsLayout->setSpacing(4);
m_buttonsLayout->addStretch();
scrollArea->setWidget(m_buttonsContainer);
layout->addWidget(scrollArea, 1);
connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked);
connect(m_deleteBtn, &QPushButton::clicked, this, &BlueprintPanel::onDeleteClicked);
}
void BlueprintPanel::onSelectionChanged(const std::vector<EntityId>& ids)
{
m_selectedIds = ids;
refreshButtonStates();
}
void BlueprintPanel::onStateUpdated(Tick /*tick*/, int blocks, double /*speed*/)
{
m_currentBlocks = blocks;
refreshButtonStates();
}
void BlueprintPanel::clearActiveBlueprintButton()
{
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
}
m_activeIndex = -1;
refreshButtonStates();
}
void BlueprintPanel::onCreateClicked()
{
if (m_selectedIds.empty()) { return; }
Blueprint bp = createBlueprintFromSelection();
if (bp.buildings.empty()) { return; }
bool ok = false;
const QString name = QInputDialog::getText(
this, "Create Blueprint", "Blueprint name:", QLineEdit::Normal, QString(), &ok);
if (!ok || name.trimmed().isEmpty()) { return; }
bp.name = name.trimmed();
m_blueprints.push_back(bp);
rebuildButtons();
}
void BlueprintPanel::onDeleteClicked()
{
if (!m_deleteMode)
{
m_deleteMode = true;
m_deleteBtn->setChecked(true);
if (m_activeIndex >= 0)
{
clearActiveBlueprintButton();
emit exitBlueprintModeRequested();
}
}
else
{
m_deleteMode = false;
m_deleteBtn->setChecked(false);
}
}
void BlueprintPanel::onBlueprintButtonClicked(int index)
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
if (m_deleteMode)
{
m_blueprints.erase(m_blueprints.begin() + index);
m_deleteMode = false;
m_deleteBtn->setChecked(false);
m_activeIndex = -1;
rebuildButtons();
return;
}
if (m_activeIndex == index)
{
clearActiveBlueprintButton();
emit exitBlueprintModeRequested();
return;
}
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(m_activeIndex)]->setChecked(false);
}
m_activeIndex = index;
m_blueprintButtons[static_cast<std::size_t>(index)]->setChecked(true);
emit blueprintPlacementRequested(m_blueprints[static_cast<std::size_t>(index)]);
}
Blueprint BlueprintPanel::createBlueprintFromSelection() const
{
struct Entry
{
const Building* building;
};
std::vector<Entry> entries;
entries.reserve(m_selectedIds.size());
for (const EntityId id : m_selectedIds)
{
const Building* b = m_sim->buildings().findBuilding(id);
if (b) { entries.push_back({ b }); }
}
if (entries.empty()) { return Blueprint{}; }
int minX = INT_MAX, maxX = INT_MIN;
int minY = INT_MAX, maxY = INT_MIN;
for (const Entry& e : entries)
{
for (const QPoint& cell : e.building->bodyCells)
{
minX = std::min(minX, cell.x());
maxX = std::max(maxX, cell.x());
minY = std::min(minY, cell.y());
maxY = std::max(maxY, cell.y());
}
}
const QPoint center((minX + maxX) / 2, (minY + maxY) / 2);
Blueprint bp;
bp.buildings.reserve(entries.size());
for (const Entry& e : entries)
{
BlueprintBuilding bb;
bb.type = e.building->type;
bb.rotation = e.building->rotation;
bb.offset = e.building->anchor - center;
bp.buildings.push_back(bb);
}
return bp;
}
int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
{
int total = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
for (const BuildingDef& def : m_config->buildings.buildings)
{
if (def.type == bb.type)
{
total += def.cost;
break;
}
}
}
return total;
}
void BlueprintPanel::rebuildButtons()
{
for (QPushButton* btn : m_blueprintButtons)
{
m_buttonsLayout->removeWidget(btn);
delete btn;
}
m_blueprintButtons.clear();
for (int i = 0; i < static_cast<int>(m_blueprints.size()); ++i)
{
const Blueprint& bp = m_blueprints[static_cast<std::size_t>(i)];
const int cost = computeBlueprintCost(bp);
const QString label = bp.name + "\n" + QString::number(cost) + " Blocks";
QPushButton* btn = new QPushButton(label, m_buttonsContainer);
btn->setCheckable(true);
btn->setFixedHeight(48);
m_buttonsLayout->insertWidget(i, btn);
const int capturedIndex = i;
connect(btn, &QPushButton::clicked, this, [this, capturedIndex]() {
onBlueprintButtonClicked(capturedIndex);
});
m_blueprintButtons.push_back(btn);
}
refreshButtonStates();
}
void BlueprintPanel::refreshButtonStates()
{
m_createBtn->setEnabled(!m_selectedIds.empty());
for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i)
{
const int cost = computeBlueprintCost(m_blueprints[static_cast<std::size_t>(i)]);
const bool canAfford = m_currentBlocks >= cost;
m_blueprintButtons[static_cast<std::size_t>(i)]->setEnabled(
canAfford || m_activeIndex == i);
}
}

56
src/ui/BlueprintPanel.h Normal file
View File

@@ -0,0 +1,56 @@
#pragma once
#include <vector>
#include <QWidget>
#include "Blueprint.h"
#include "EntityId.h"
#include "GameConfig.h"
#include "Tick.h"
class Simulation;
class QPushButton;
class QScrollArea;
class QVBoxLayout;
class BlueprintPanel : public QWidget
{
Q_OBJECT
public:
BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
public slots:
void onSelectionChanged(const std::vector<EntityId>& ids);
void onStateUpdated(Tick tick, int blocks, double speed);
void clearActiveBlueprintButton();
signals:
void blueprintPlacementRequested(Blueprint blueprint);
void exitBlueprintModeRequested();
private slots:
void onCreateClicked();
void onDeleteClicked();
void onBlueprintButtonClicked(int index);
private:
Blueprint createBlueprintFromSelection() const;
int computeBlueprintCost(const Blueprint& bp) const;
void rebuildButtons();
void refreshButtonStates();
Simulation* m_sim;
const GameConfig* m_config;
std::vector<EntityId> m_selectedIds;
int m_currentBlocks;
bool m_deleteMode;
int m_activeIndex;
std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons;
QPushButton* m_createBtn;
QPushButton* m_deleteBtn;
QWidget* m_buttonsContainer;
QVBoxLayout* m_buttonsLayout;
};

View File

@@ -7,6 +7,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/Blueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
PARENT_SCOPE
)
@@ -18,5 +20,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
PARENT_SCOPE
)

View File

@@ -452,6 +452,29 @@ void GameWorldView::stepSpeed(int delta)
setGameSpeed(kSpeeds[next]);
}
void GameWorldView::placeBlueprintAtTile(QPoint center)
{
const Blueprint& bp = *m_blueprintMode;
for (const BlueprintBuilding& bb : bp.buildings)
{
if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; }
}
int totalCost = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = findBuildingDef(bb.type);
if (def) { totalCost += def->cost; }
}
if (m_sim->buildingBlocksStock() < totalCost) { return; }
for (const BlueprintBuilding& bb : bp.buildings)
{
m_sim->tryPlaceBuilding(bb.type, center + bb.offset, bb.rotation);
}
}
void GameWorldView::placeAtTile(QPoint tile)
{
if (!m_builderType.has_value())
@@ -776,6 +799,32 @@ void GameWorldView::drawOverlays(QPainter& painter)
}
}
// Blueprint placement ghost
if (m_blueprintMode.has_value())
{
for (const BlueprintBuilding& bb : m_blueprintMode->buildings)
{
const QPoint anchor = m_blueprintGhostTile + bb.offset;
const bool valid = isValidPlacement(bb.type, anchor, bb.rotation);
const QColor& ghostColor = valid
? m_visuals->overlays.ghostValid
: m_visuals->overlays.ghostInvalid;
const BuildingDef* def = findBuildingDef(bb.type);
if (!def) { continue; }
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, bb.rotation);
for (const QPoint& cell : parsed.bodyCells)
{
painter.fillRect(tileRect(anchor + cell), ghostColor);
}
for (const Port& port : parsed.outputPorts)
{
drawPortGlyph(painter,
anchor + portBodyTile(port.tile, port.direction),
port.direction, Qt::white);
}
}
}
// Demolish hover tint
if (m_demolishMode && m_demolishHoverId != kInvalidEntityId)
{
@@ -883,6 +932,14 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
m_ghostRotation = rotateClockwise(m_ghostRotation);
m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation);
}
else if (m_blueprintMode.has_value())
{
for (BlueprintBuilding& bb : m_blueprintMode->buildings)
{
bb.offset = QPoint(-bb.offset.y(), bb.offset.x());
bb.rotation = rotateClockwise(bb.rotation);
}
}
break;
case Qt::Key_Q:
if (m_builderType.has_value())
@@ -890,6 +947,14 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
m_ghostRotation = rotateCounterClockwise(m_ghostRotation);
m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation);
}
else if (m_blueprintMode.has_value())
{
for (BlueprintBuilding& bb : m_blueprintMode->buildings)
{
bb.offset = QPoint(bb.offset.y(), -bb.offset.x());
bb.rotation = rotateCounterClockwise(bb.rotation);
}
}
break;
case Qt::Key_Escape:
emit escapeMenuRequested();
@@ -919,9 +984,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
{
if (event->button() != Qt::LeftButton)
{
if (event->button() == Qt::RightButton && m_builderType.has_value())
if (event->button() == Qt::RightButton)
{
exitBuilderMode();
if (m_builderType.has_value()) { exitBuilderMode(); }
else if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
}
return;
}
@@ -942,6 +1008,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
placeAtTile(tile);
}
}
else if (m_blueprintMode.has_value())
{
placeBlueprintAtTile(tile);
}
else if (m_demolishMode)
{
EntityId hovered = buildingAtTile(tile);
@@ -1012,6 +1082,10 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
placeAtTile(tile);
}
}
else if (m_blueprintMode.has_value())
{
m_blueprintGhostTile = tile;
}
else if (m_demolishMode)
{
m_demolishHoverId = buildingAtTile(tile);
@@ -1089,6 +1163,7 @@ void GameWorldView::toggleDemolishMode()
else
{
if (m_builderType.has_value()) { exitBuilderMode(); }
if (m_blueprintMode.has_value()) { exitBlueprintMode(); }
m_demolishMode = true;
}
emit demolishModeChanged(m_demolishMode);
@@ -1100,9 +1175,25 @@ void GameWorldView::enterBuilderMode(BuildingType type)
m_ghostRotation = Rotation::East;
m_ghostValid = false;
m_demolishMode = false;
m_blueprintMode.reset();
emit demolishModeChanged(false);
}
void GameWorldView::enterBlueprintMode(Blueprint blueprint)
{
if (m_builderType.has_value()) { exitBuilderMode(); }
m_demolishMode = false;
emit demolishModeChanged(false);
m_blueprintGhostTile = m_ghostTile;
m_blueprintMode = std::move(blueprint);
}
void GameWorldView::exitBlueprintMode()
{
m_blueprintMode.reset();
emit blueprintModeExited();
}
void GameWorldView::exitBuilderMode()
{
m_builderType.reset();
@@ -1127,6 +1218,7 @@ void GameWorldView::setGameSpeed(double multiplier)
void GameWorldView::resetForNewGame()
{
exitBuilderMode();
exitBlueprintMode();
m_activeBeams.clear();
m_toasts.clear();
m_ghostRotation = Rotation::East;

View File

@@ -11,6 +11,7 @@
#include <QTimer>
#include <QVector2D>
#include "Blueprint.h"
#include "SchematicDropEvent.h"
#include "BuildingType.h"
#include "EntityId.h"
@@ -46,6 +47,7 @@ signals:
void stateUpdated(Tick tick, int blocks, double speed);
void gameOver();
void builderModeExited();
void blueprintModeExited();
void escapeMenuRequested();
void demolishModeChanged(bool active);
@@ -55,6 +57,8 @@ public:
public slots:
void enterBuilderMode(BuildingType type);
void exitBuilderMode();
void enterBlueprintMode(Blueprint blueprint);
void exitBlueprintMode();
void toggleDemolishMode();
void setGameSpeed(double multiplier);
void resetForNewGame();
@@ -101,6 +105,8 @@ private:
void drawPortGlyph(QPainter& painter, QPoint bodyTile,
Rotation direction, const QColor& color);
void placeBlueprintAtTile(QPoint center);
std::optional<QVector2D> entityPosition(EntityId id) const;
void stepSpeed(int delta);
void placeAtTile(QPoint tile);
@@ -145,6 +151,9 @@ private:
std::set<QPoint, QPointCompare> m_beltDragTiles;
bool m_dragging;
std::optional<Blueprint> m_blueprintMode;
QPoint m_blueprintGhostTile;
bool m_demolishMode;
EntityId m_demolishHoverId;

View File

@@ -7,6 +7,7 @@
#include <QResizeEvent>
#include <QVBoxLayout>
#include "BlueprintPanel.h"
#include "BuildButtonGrid.h"
#include "ConfigLoader.h"
#include "GameWorldView.h"
@@ -36,9 +37,11 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->config(), m_bottomPanel);
m_buildButtonGrid = new BuildButtonGrid(&sim->config(), m_bottomPanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->config(), m_bottomPanel);
bottomLayout->addWidget(m_selectedBuildingPanel, 1);
bottomLayout->addWidget(m_buildButtonGrid, 1);
bottomLayout->addWidget(m_blueprintPanel, 1);
// Signals: game world → other panels
connect(m_gameWorldView, &GameWorldView::selectionChanged,
@@ -75,6 +78,22 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
connect(m_gameWorldView, &GameWorldView::demolishModeChanged,
m_buildButtonGrid, &BuildButtonGrid::setDemolishModeActive);
// Signals: blueprint panel ↔ game world
connect(m_gameWorldView, &GameWorldView::selectionChanged,
m_blueprintPanel, &BlueprintPanel::onSelectionChanged);
connect(m_gameWorldView, &GameWorldView::stateUpdated,
m_blueprintPanel, &BlueprintPanel::onStateUpdated);
connect(m_blueprintPanel, &BlueprintPanel::blueprintPlacementRequested,
m_gameWorldView, &GameWorldView::enterBlueprintMode);
connect(m_blueprintPanel, &BlueprintPanel::exitBlueprintModeRequested,
m_gameWorldView, &GameWorldView::exitBlueprintMode);
connect(m_gameWorldView, &GameWorldView::blueprintModeExited,
m_blueprintPanel, &BlueprintPanel::clearActiveBlueprintButton);
// Signals: header bar → game world
connect(m_headerBar, &HeaderBar::speedChanged,
m_gameWorldView, &GameWorldView::setGameSpeed);

View File

@@ -12,6 +12,7 @@ class GameWorldView;
class HeaderBar;
class SelectedBuildingPanel;
class BuildButtonGrid;
class BlueprintPanel;
class QResizeEvent;
class MainWindow : public QWidget
@@ -39,5 +40,6 @@ private:
HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel;
BuildButtonGrid* m_buildButtonGrid;
BlueprintPanel* m_blueprintPanel;
QWidget* m_bottomPanel;
};