From 71677b806a8d1c340851e670bb4a19b61397045f Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Sun, 26 Apr 2026 21:56:49 +0200 Subject: [PATCH] implement blueprints --- src/ui/Blueprint.h | 22 ++++ src/ui/BlueprintPanel.cpp | 247 ++++++++++++++++++++++++++++++++++++++ src/ui/BlueprintPanel.h | 56 +++++++++ src/ui/CMakeLists.txt | 3 + src/ui/GameWorldView.cpp | 98 ++++++++++++++- src/ui/GameWorldView.h | 9 ++ src/ui/MainWindow.cpp | 19 +++ src/ui/MainWindow.h | 2 + 8 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 src/ui/Blueprint.h create mode 100644 src/ui/BlueprintPanel.cpp create mode 100644 src/ui/BlueprintPanel.h diff --git a/src/ui/Blueprint.h b/src/ui/Blueprint.h new file mode 100644 index 0000000..b1f0ee8 --- /dev/null +++ b/src/ui/Blueprint.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include +#include + +#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 buildings; +}; diff --git a/src/ui/BlueprintPanel.cpp b/src/ui/BlueprintPanel.cpp new file mode 100644 index 0000000..6f8413a --- /dev/null +++ b/src/ui/BlueprintPanel.cpp @@ -0,0 +1,247 @@ +#include "BlueprintPanel.h" + +#include +#include + +#include +#include +#include +#include + +#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& 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(m_blueprintButtons.size())) + { + m_blueprintButtons[static_cast(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(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(m_blueprintButtons.size())) + { + m_blueprintButtons[static_cast(m_activeIndex)]->setChecked(false); + } + + m_activeIndex = index; + m_blueprintButtons[static_cast(index)]->setChecked(true); + emit blueprintPlacementRequested(m_blueprints[static_cast(index)]); +} + +Blueprint BlueprintPanel::createBlueprintFromSelection() const +{ + struct Entry + { + const Building* building; + }; + std::vector 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(m_blueprints.size()); ++i) + { + const Blueprint& bp = m_blueprints[static_cast(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(m_blueprintButtons.size()); ++i) + { + const int cost = computeBlueprintCost(m_blueprints[static_cast(i)]); + const bool canAfford = m_currentBlocks >= cost; + m_blueprintButtons[static_cast(i)]->setEnabled( + canAfford || m_activeIndex == i); + } +} diff --git a/src/ui/BlueprintPanel.h b/src/ui/BlueprintPanel.h new file mode 100644 index 0000000..a936411 --- /dev/null +++ b/src/ui/BlueprintPanel.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include + +#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& 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 m_selectedIds; + int m_currentBlocks; + bool m_deleteMode; + int m_activeIndex; + std::vector m_blueprints; + std::vector m_blueprintButtons; + QPushButton* m_createBtn; + QPushButton* m_deleteBtn; + QWidget* m_buttonsContainer; + QVBoxLayout* m_buttonsLayout; +}; diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index a55d6a2..5443ab3 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -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 ) diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 13e3988..5b7c922 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -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); @@ -1088,7 +1162,8 @@ void GameWorldView::toggleDemolishMode() } else { - if (m_builderType.has_value()) { exitBuilderMode(); } + 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; diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 236c58a..816e33c 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -11,6 +11,7 @@ #include #include +#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 entityPosition(EntityId id) const; void stepSpeed(int delta); void placeAtTile(QPoint tile); @@ -145,6 +151,9 @@ private: std::set m_beltDragTiles; bool m_dragging; + std::optional m_blueprintMode; + QPoint m_blueprintGhostTile; + bool m_demolishMode; EntityId m_demolishHoverId; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index c953787..224ba3a 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -7,6 +7,7 @@ #include #include +#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); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 77df407..d991c06 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -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; };