move blueprints out of the sidebar into Ctrl+C / Ctrl+V dialogs

The blueprint panel is gone; the side panel column now holds the selected
building panel alone at full height. Saving is Ctrl+C on the current
selection, and picking one for placement is a new frameless modal card grid
opened with Ctrl+V, or handed to directly after a confirmed save.

BlueprintPanel sheds its widget half and becomes BlueprintLibrary: the list,
its disk round-trip, capture, and the T hotkey, with no widget of its own.
MainWindow owns it and drives both dialogs, because ModalPauseScope needs a
GameWorldView and only MainWindow has one. Both handlers hold a single pause
scope and a single dim scope across the save-to-select handoff, so the dim
does not blink and the simulation is not resumed in between.

The two facts a card derives -- the per-type contents summary and the plain
cost total -- go into lib/sim next to captureBlueprintFromSelection so they
can be unit tested; src/ui is off the test include path. They are kept apart
from GameWorldView's placement total, which excludes locked types and
rotate-in-place targets and is a different number by design.

A card's delete icon is a sibling of the card body rather than one of its
children: Qt disables a widget's children along with it, and the delete has
to stay live on an unaffordable card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
2026-08-06 11:29:48 +02:00
parent d2d93347ad
commit a86cbd3fbd
21 changed files with 975 additions and 385 deletions

View File

@@ -0,0 +1,11 @@
#pragma once
#include "Event.h"
// Ctrl+C: save the current building selection as a named blueprint
// (REQ-UI-BLUEPRINT-CREATE, REQ-UI-HOTKEYS). MainWindow owns the modal flow, because
// it is the only widget that can pause the game and raise the dim overlay; whether
// anything placeable is selected is decided there, not by the key handler.
class BlueprintSaveRequestedEvent : public Event
{
};

View File

@@ -0,0 +1,11 @@
#pragma once
#include "Event.h"
// Ctrl+V: open the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG,
// REQ-UI-HOTKEYS). The Ctrl+C path does not go through this event -- it opens the
// dialog directly so one pause scope and one dim scope span both dialogs
// (REQ-UI-MODAL-DIM).
class BlueprintSelectionRequestedEvent : public Event
{
};

View File

@@ -15,6 +15,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSaveRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h

View File

@@ -3,10 +3,14 @@
#include <algorithm> #include <algorithm>
#include <climits> #include <climits>
#include <cstddef>
#include <map>
#include "BeltSystem.h" #include "BeltSystem.h"
#include "Building.h" #include "Building.h"
#include "BuildingsConfig.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "DisplayName.h"
#include "Simulation.h" #include "Simulation.h"
namespace namespace
@@ -49,6 +53,21 @@ std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, Building
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells; resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
return resolved; return resolved;
} }
// Position of a building type in buildings.toml, which is the order the build button
// bar lays out its buttons (REQ-UI-BUILD-BAR) and so the tie-break order for a
// blueprint's contents line. A type with no config entry sorts after every known one.
std::size_t configOrderIndex(BuildingType type, const BuildingsConfig& buildings)
{
for (std::size_t i = 0; i < buildings.buildings.size(); ++i)
{
if (buildings.buildings[i].type == type)
{
return i;
}
}
return buildings.buildings.size();
}
} // namespace } // namespace
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id) std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
@@ -166,3 +185,56 @@ bool selectionHasPlaceableBuilding(const Simulation& sim,
} }
return false; return false;
} }
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
const Blueprint& blueprint, const BuildingsConfig& buildings)
{
std::map<BuildingType, int> counts;
for (const BlueprintBuilding& building : blueprint.buildings)
{
++counts[building.type];
}
// The config index is carried through the sort so the comparator stays a strict
// total order; the enum value is the final tie-break, which only two config-less
// types could ever reach.
struct RankedType
{
BuildingType type;
int count;
std::size_t order;
};
std::vector<RankedType> ranked;
ranked.reserve(counts.size());
for (const std::pair<const BuildingType, int>& entry : counts)
{
ranked.push_back({entry.first, entry.second,
configOrderIndex(entry.first, buildings)});
}
std::sort(ranked.begin(), ranked.end(),
[](const RankedType& left, const RankedType& right)
{
if (left.count != right.count) { return left.count > right.count; }
if (left.order != right.order) { return left.order < right.order; }
return static_cast<int>(left.type) < static_cast<int>(right.type);
});
std::vector<BlueprintContentEntry> summary;
summary.reserve(ranked.size());
for (const RankedType& entry : ranked)
{
summary.push_back({toDisplayName(buildingTypeId(entry.type)), entry.count});
}
return summary;
}
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings)
{
int total = 0;
for (const BlueprintBuilding& building : blueprint.buildings)
{
const BuildingDef* def = buildings.findBuildingDef(building.type);
if (def) { total += def->cost; }
}
return total;
}

View File

@@ -11,6 +11,7 @@
#include "ShipLayout.h" #include "ShipLayout.h"
class Simulation; class Simulation;
struct BuildingsConfig;
// The user-configurable settings of a single building or construction site: the // The user-configurable settings of a single building or construction site: the
// selected recipe / ship schematic, the shipyard module layout, and (for // selected recipe / ship schematic, the shipyard module layout, and (for
@@ -49,6 +50,29 @@ Blueprint captureBlueprintFromSelection(const Simulation& sim,
const std::vector<BuildingId>& selectedIds); const std::vector<BuildingId>& selectedIds);
// True if any selected id refers to a player-placeable building or construction site // True if any selected id refers to a player-placeable building or construction site
// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE). // (the condition under which Ctrl+C opens the blueprint save dialog,
// REQ-UI-BLUEPRINT-CREATE).
bool selectionHasPlaceableBuilding(const Simulation& sim, bool selectionHasPlaceableBuilding(const Simulation& sim,
const std::vector<BuildingId>& selectedIds); const std::vector<BuildingId>& selectedIds);
// One "<building name> x <count>" entry of a blueprint card's contents line
// (REQ-UI-BLUEPRINT-CARD).
struct BlueprintContentEntry
{
std::string buildingName;
int count;
};
// Summarizes what a blueprint holds: one entry per building type it contains, ordered
// by descending count with ties broken by the order the types appear in buildings.toml
// (which is the order of the build button bar, REQ-UI-BUILD-BAR). Building types absent
// from the config sort last. Derived display data for the blueprint card, kept here so
// it is unit-testable rather than buried in the dialog (REQ-UI-BLUEPRINT-CARD).
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
const Blueprint& blueprint, const BuildingsConfig& buildings);
// Plain sum of the placement cost of every building in the blueprint
// (REQ-UI-BLUEPRINT-CARD). Distinct from the total charged on placement
// (REQ-UI-BLUEPRINT-PLACE), which additionally excludes locked building types and
// rotate-in-place targets; see GameWorldView's placement path.
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings);

View File

@@ -56,7 +56,7 @@ static Rotation rotCCW(Rotation r)
return Rotation::East; return Rotation::East;
} }
// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building // Mirror of BlueprintLibrary::createBlueprintFromSelection: given per-building
// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division // (anchor, bodyCells, type, rotation), compute Blueprint with floor-division
// bounding-box center and per-building tile offsets. // bounding-box center and per-building tile offsets.
struct BuildingSpec struct BuildingSpec
@@ -149,7 +149,7 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
} }
} }
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter: // Mirrors BlueprintLibrary::createBlueprintFromSelection's player-placeable filter:
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false // building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
// are silently excluded before the bounding-box center and offsets are computed. // are silently excluded before the bounding-box center and offsets are computed.
static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs, static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs,

View File

@@ -82,7 +82,7 @@ TEST_CASE("Only one mode is active at a time", "[buildmode]")
TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]") TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]")
{ {
// Regression: entering builder mode used to drop the blueprint silently, so the // Regression: entering builder mode used to drop the blueprint silently, so the
// blueprint panel kept its button highlighted for a mode that was over. // blueprint library kept tracking an active blueprint for a mode that was over.
BuildModeController controller; BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint()); controller.enterBlueprintMode(makeBlueprint());

View File

@@ -139,3 +139,100 @@ TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]"
Simulation sim(loadTestConfig(), 7); Simulation sim(loadTestConfig(), 7);
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value()); CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
} }
// The derived facts a blueprint card shows (REQ-UI-BLUEPRINT-CARD). They live here
// rather than in the dialog so they can be tested: src/ui is off the test include path.
namespace
{
Blueprint makeBlueprint(const std::vector<BuildingType>& types)
{
Blueprint blueprint;
for (const BuildingType type : types)
{
BlueprintBuilding building;
building.type = type;
building.rotation = Rotation::East;
blueprint.buildings.push_back(building);
}
return blueprint;
}
} // namespace
TEST_CASE("computeBlueprintCost sums the constituent placement costs", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
// belt 2 + belt 2 + miner 15.
const Blueprint blueprint = makeBlueprint(
{BuildingType::Belt, BuildingType::Belt, BuildingType::Miner});
CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 19);
}
TEST_CASE("computeBlueprintCost ignores types absent from buildings.toml", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
REQUIRE(cfg.buildings.findBuildingDef(BuildingType::Hq) == nullptr);
const Blueprint blueprint = makeBlueprint({BuildingType::Belt, BuildingType::Hq});
CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 2);
}
TEST_CASE("computeBlueprintCost is zero for an empty blueprint", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
CHECK(computeBlueprintCost(Blueprint{}, cfg.buildings) == 0);
}
TEST_CASE("summarizeBlueprintContents orders by descending count", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
const Blueprint blueprint = makeBlueprint(
{BuildingType::Belt, BuildingType::Smelter, BuildingType::Miner,
BuildingType::Miner, BuildingType::Smelter, BuildingType::Miner});
const std::vector<BlueprintContentEntry> summary =
summarizeBlueprintContents(blueprint, cfg.buildings);
REQUIRE(summary.size() == 3);
CHECK(summary[0].buildingName == "Miner");
CHECK(summary[0].count == 3);
CHECK(summary[1].buildingName == "Smelter");
CHECK(summary[1].count == 2);
CHECK(summary[2].buildingName == "Belt");
CHECK(summary[2].count == 1);
}
TEST_CASE("summarizeBlueprintContents breaks ties in buildings.toml order", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
// Miner is declared after Belt in buildings.toml, which is the order the build
// button bar lays its buttons out, so Belt wins the tie despite the equal counts
// and despite sorting later by enum value.
const Blueprint blueprint = makeBlueprint(
{BuildingType::Miner, BuildingType::Belt, BuildingType::Miner, BuildingType::Belt});
const std::vector<BlueprintContentEntry> summary =
summarizeBlueprintContents(blueprint, cfg.buildings);
REQUIRE(summary.size() == 2);
CHECK(summary[0].buildingName == "Belt");
CHECK(summary[1].buildingName == "Miner");
}
TEST_CASE("summarizeBlueprintContents spells multi-word ids as display names", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
const Blueprint blueprint = makeBlueprint({BuildingType::TunnelEntry});
const std::vector<BlueprintContentEntry> summary =
summarizeBlueprintContents(blueprint, cfg.buildings);
REQUIRE(summary.size() == 1);
CHECK(summary[0].buildingName == "Tunnel Entry");
}
TEST_CASE("summarizeBlueprintContents is empty for an empty blueprint", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
CHECK(summarizeBlueprintContents(Blueprint{}, cfg.buildings).empty());
}

175
src/ui/BlueprintLibrary.cpp Normal file
View File

@@ -0,0 +1,175 @@
#include "BlueprintLibrary.h"
#include <cstddef>
#include <utility>
#include <QCoreApplication>
#include <QFile>
#include <QMessageBox>
#include <QObject>
#include <QStringList>
#include "BlueprintPlacementRequestedEvent.h"
#include "BlueprintSerializer.h"
#include "EventManager.h"
#include "ExitBlueprintModeRequestedEvent.h"
#include "BuildingConfig.h"
#include "Simulation.h"
BlueprintLibrary::BlueprintLibrary(Simulation* sim, const GameConfig* config,
QWidget* dialogParent)
: m_sim(sim)
, m_config(config)
, m_dialogParent(dialogParent)
{
loadFromDisk();
registerForEvents();
}
BlueprintLibrary::~BlueprintLibrary()
{
saveToDisk();
unregisterForEvents();
}
bool BlueprintLibrary::getCanCaptureSelection() const
{
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
}
void BlueprintLibrary::saveSelectionAs(const QString& name)
{
Blueprint blueprint = createBlueprintFromSelection();
if (blueprint.buildings.empty()) { return; }
blueprint.name = name;
m_blueprints.push_back(std::move(blueprint));
}
const std::vector<Blueprint>& BlueprintLibrary::getBlueprints() const
{
return m_blueprints;
}
QString BlueprintLibrary::getContentsSummary(int index) const
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return QString(); }
QStringList entries;
for (const BlueprintContentEntry& entry : summarizeBlueprintContents(
m_blueprints[static_cast<std::size_t>(index)], m_config->buildings))
{
// The same "<type> x <count>" notation the building multi-selection summary
// uses (REQ-UI-MULTI-SELECTION), which also sidesteps plural forms.
entries << QObject::tr("%1 x %2")
.arg(QString::fromStdString(entry.buildingName))
.arg(entry.count);
}
return entries.join(QStringLiteral(", "));
}
int BlueprintLibrary::getCost(int index) const
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return 0; }
return computeBlueprintCost(m_blueprints[static_cast<std::size_t>(index)],
m_config->buildings);
}
bool BlueprintLibrary::getCanAfford(int index) const
{
return m_sim->getBuildingBlocksStock() >= getCost(index);
}
void BlueprintLibrary::remove(int index)
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
if (m_activeIndex == index)
{
m_activeIndex = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
}
else if (m_activeIndex.has_value() && *m_activeIndex > index)
{
--*m_activeIndex;
}
m_blueprints.erase(m_blueprints.begin() + index);
}
void BlueprintLibrary::beginPlacement(int index)
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
m_activeIndex = index;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(
m_blueprints[static_cast<std::size_t>(index)]));
}
void BlueprintLibrary::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
m_selectedBuildingIds = event->ids;
}
void BlueprintLibrary::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
{
m_activeIndex = std::nullopt;
}
void BlueprintLibrary::handleEvent(
std::shared_ptr<const TemporaryBlueprintRequestedEvent> /*event*/)
{
// Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and
// enter placement mode without adding it to the list or persisting it. If nothing
// player-placeable is selected, do nothing.
Blueprint blueprint = createBlueprintFromSelection();
if (blueprint.buildings.empty()) { return; }
// No saved blueprint is active while a temporary one is being placed.
m_activeIndex = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(std::move(blueprint)));
}
Blueprint BlueprintLibrary::createBlueprintFromSelection() const
{
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE).
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
}
void BlueprintLibrary::loadFromDisk()
{
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
try
{
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
}
catch (const std::exception& e)
{
QMessageBox::critical(m_dialogParent, QObject::tr("Load Failed"),
QObject::tr("Failed to load blueprints:\n%1").arg(e.what()));
m_blueprints.clear();
}
}
void BlueprintLibrary::saveToDisk() const
{
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
try
{
const std::string content = BlueprintSerializer::serialize(m_blueprints);
file.write(QByteArray::fromStdString(content));
}
catch (...) {}
}

86
src/ui/BlueprintLibrary.h Normal file
View File

@@ -0,0 +1,86 @@
#pragma once
#include <optional>
#include <vector>
#include <QString>
#include "Blueprint.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingId.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "SelectionChangedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
class Simulation;
class QWidget;
// The player's saved blueprints: the list itself, its persistence (REQ-UI-BLUEPRINT-SAVE,
// REQ-UI-BLUEPRINT-LOAD), capture from the current selection (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-TEMP), and entry into blueprint placement mode.
//
// Deliberately not a widget: blueprints have no permanent place on screen any more
// (REQ-UI-BLUEPRINT-DIALOG), and the modal dialogs that present them must be driven from
// MainWindow, the only widget that can pause the game (ModalPauseScope) and raise the dim
// overlay. This class is the model those dialogs read and mutate.
class BlueprintLibrary : public CombinedEventHandler<SelectionChangedEvent,
BlueprintModeExitedEvent,
TemporaryBlueprintRequestedEvent>
{
public:
// dialogParent parents the load-failure message box (REQ-UI-BLUEPRINT-LOAD) and is
// not owned.
BlueprintLibrary(Simulation* sim, const GameConfig* config, QWidget* dialogParent);
~BlueprintLibrary();
// True when the current selection holds at least one player-placeable building or
// construction site -- the condition under which Ctrl+C has any effect
// (REQ-UI-BLUEPRINT-CREATE).
bool getCanCaptureSelection() const;
// Captures the current selection under the given name and appends it to the list.
// Silently does nothing when nothing player-placeable is selected.
void saveSelectionAs(const QString& name);
const std::vector<Blueprint>& getBlueprints() const;
// The blueprint's contents line, ready for its card: one "<name> x <count>" entry
// per building type, comma-separated, ordered by summarizeBlueprintContents
// (REQ-UI-BLUEPRINT-CARD).
QString getContentsSummary(int index) const;
// Sum of the constituent buildings' placement costs (REQ-UI-BLUEPRINT-CARD).
int getCost(int index) const;
// Whether the player can currently afford the blueprint's total cost, which is what
// enables or greys out its card (REQ-UI-BLUEPRINT-CARD).
bool getCanAfford(int index) const;
// Removes the blueprint, exiting blueprint placement mode if it was the active one
// (REQ-UI-BLUEPRINT-DELETE).
void remove(int index);
// Enters blueprint placement mode for the blueprint (REQ-UI-BLUEPRINT-MODE).
void beginPlacement(int index);
private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> event) override;
Blueprint createBlueprintFromSelection() const;
void loadFromDisk();
void saveToDisk() const;
// The simulation is the single source of truth for the block stock and the
// selection; the change events are only refresh signals.
Simulation* m_sim;
const GameConfig* m_config;
QWidget* m_dialogParent;
std::vector<BuildingId> m_selectedBuildingIds;
std::vector<Blueprint> m_blueprints;
// Index of the blueprint currently in placement mode, so deleting it can exit that
// mode (REQ-UI-BLUEPRINT-DELETE). nullopt = no saved blueprint is being placed.
std::optional<int> m_activeIndex;
};

View File

@@ -1,281 +0,0 @@
#include "BlueprintPanel.h"
#include <algorithm>
#include <climits>
#include <QCoreApplication>
#include <QFile>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
#include "BlueprintPlacementRequestedEvent.h"
#include "BlueprintSerializer.h"
#include "BuildingBlocksChangedEvent.h"
#include "EventManager.h"
#include "ExitBlueprintModeRequestedEvent.h"
#include "Building.h"
#include "BuildingConfig.h"
#include "BuildingSystem.h"
#include "Simulation.h"
BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
m_createBtn = new QPushButton(tr("Create Blueprint"), this);
m_createBtn->setFixedHeight(48);
m_createBtn->setEnabled(false);
layout->addWidget(m_createBtn);
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);
loadFromDisk();
rebuildButtons();
registerForEvents();
}
BlueprintPanel::~BlueprintPanel()
{
saveToDisk();
unregisterForEvents();
}
void BlueprintPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
{
m_selectedBuildingIds = ids;
refreshButtonStates();
}
void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{
refreshButtonStates();
}
void BlueprintPanel::clearActiveBlueprintButton()
{
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
}
m_activeIndex = std::nullopt;
refreshButtonStates();
}
void BlueprintPanel::onCreateClicked()
{
if (m_selectedBuildingIds.empty()) { return; }
Blueprint bp = createBlueprintFromSelection();
if (bp.buildings.empty()) { return; }
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; }
bp.name = name.trimmed();
m_blueprints.push_back(bp);
rebuildButtons();
}
void BlueprintPanel::onDeleteBlueprintClicked(int index)
{
if (m_activeIndex == index)
{
m_activeIndex = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
}
else if (m_activeIndex.has_value() && *m_activeIndex > index)
{
--*m_activeIndex;
}
m_blueprints.erase(m_blueprints.begin() + index);
rebuildButtons();
}
void BlueprintPanel::onBlueprintButtonClicked(int index)
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
if (m_activeIndex == index)
{
clearActiveBlueprintButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
return;
}
if (m_activeIndex.has_value() && *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);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(m_blueprints[static_cast<std::size_t>(index)]));
}
Blueprint BlueprintPanel::createBlueprintFromSelection() const
{
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE).
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
}
int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
{
int total = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (def) { total += def->cost; }
}
return total;
}
void BlueprintPanel::rebuildButtons()
{
while (m_buttonsLayout->count() > 1)
{
QLayoutItem* item = m_buttonsLayout->takeAt(0);
if (item->widget()) { delete item->widget(); }
delete item;
}
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" + tr("%1 Building Blocks").arg(cost);
QWidget* row = new QWidget(m_buttonsContainer);
QHBoxLayout* rowLayout = new QHBoxLayout(row);
rowLayout->setContentsMargins(0, 0, 0, 0);
rowLayout->setSpacing(4);
QPushButton* btn = new QPushButton(label, row);
btn->setCheckable(true);
btn->setFixedHeight(48);
QPushButton* delBtn = new QPushButton("\xc3\x97", row);
delBtn->setFixedWidth(28);
delBtn->setFixedHeight(48);
rowLayout->addWidget(btn, 1);
rowLayout->addWidget(delBtn, 0);
m_buttonsLayout->insertWidget(i, row);
const int capturedIndex = i;
connect(btn, &QPushButton::clicked, this, [this, capturedIndex]() {
onBlueprintButtonClicked(capturedIndex);
});
connect(delBtn, &QPushButton::clicked, this, [this, capturedIndex]() {
onDeleteBlueprintClicked(capturedIndex);
});
m_blueprintButtons.push_back(btn);
}
refreshButtonStates();
}
void BlueprintPanel::saveToDisk() const
{
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
try
{
const std::string content = BlueprintSerializer::serialize(m_blueprints);
file.write(QByteArray::fromStdString(content));
}
catch (...) {}
}
void BlueprintPanel::loadFromDisk()
{
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
try
{
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Load Failed"),
tr("Failed to load blueprints:\n%1").arg(e.what()));
m_blueprints.clear();
}
}
void BlueprintPanel::refreshButtonStates()
{
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds));
const int blocks = m_sim->getBuildingBlocksStock();
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 = blocks >= cost;
m_blueprintButtons[static_cast<std::size_t>(i)]->setEnabled(
canAfford || m_activeIndex == i);
}
}
void BlueprintPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
onSelectionChanged(event->ids);
}
void BlueprintPanel::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
{
clearActiveBlueprintButton();
}
void BlueprintPanel::handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> /*event*/)
{
// Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and
// enter placement mode without adding it to the list or persisting it. If nothing
// player-placeable is selected, do nothing.
Blueprint bp = createBlueprintFromSelection();
if (bp.buildings.empty()) { return; }
// No saved blueprint is active while a temporary one is being placed.
clearActiveBlueprintButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(std::move(bp)));
}

View File

@@ -1,67 +0,0 @@
#pragma once
#include <optional>
#include <vector>
#include <QWidget>
#include "Blueprint.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingBlocksChangedEvent.h"
#include "BuildingId.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "SelectionChangedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "Tick.h"
class Simulation;
class QPushButton;
class QScrollArea;
class QVBoxLayout;
class BlueprintPanel : public QWidget,
public CombinedEventHandler<BuildingBlocksChangedEvent,
SelectionChangedEvent,
BlueprintModeExitedEvent,
TemporaryBlueprintRequestedEvent>
{
Q_OBJECT
public:
BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
~BlueprintPanel() override;
private:
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> event) override;
private slots:
void onCreateClicked();
void onDeleteBlueprintClicked(int index);
void onBlueprintButtonClicked(int index);
private:
void onSelectionChanged(const std::vector<BuildingId>& ids);
void clearActiveBlueprintButton();
Blueprint createBlueprintFromSelection() const;
int computeBlueprintCost(const Blueprint& bp) const;
void rebuildButtons();
void refreshButtonStates();
void loadFromDisk();
void saveToDisk() const;
// The simulation is the single source of truth for the block stock; the
// change event is only a refresh signal.
Simulation* m_sim;
const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds;
std::optional<int> m_activeIndex; // nullopt = no blueprint selected
std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons;
QPushButton* m_createBtn;
QWidget* m_buttonsContainer;
QVBoxLayout* m_buttonsLayout;
};

View File

@@ -0,0 +1,333 @@
#include "BlueprintSelectionDialog.h"
#include <cstddef>
#include <vector>
#include <QChar>
#include <QColor>
#include <QFont>
#include <QFontMetrics>
#include <QFrame>
#include <QGridLayout>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QLayoutItem>
#include <QPainter>
#include <QPalette>
#include <QPixmap>
#include <QPoint>
#include <QPushButton>
#include <QRect>
#include <QScrollArea>
#include <QSize>
#include <QString>
#include <QStyle>
#include <QVBoxLayout>
#include "Blueprint.h"
#include "BlueprintLibrary.h"
#include "IconCaption.h"
#include "ItemIconCache.h"
namespace
{
// Fixed at two, per REQ-UI-BLUEPRINT-DIALOG; the grid scrolls rather than reflowing.
const int kGridColumnCount = 2;
// Card geometry. Every card is the same size so the grid stays even
// (REQ-UI-BLUEPRINT-CARD); the width is chosen to fit a name and a short contents
// line, and the height follows from three text rows.
const int kCardWidthPx = 220;
const int kCardPaddingPx = 10;
const int kCardRowGapPx = 4;
// Side length of the per-card delete icon and of the dialog's close button.
const int kSmallButtonSizePx = 22;
// Spacing between cards, and the dialog's own content margin.
const int kSpacingPx = 8;
// Card rows visible before the grid scrolls. The half row is deliberate: a cut-off
// card reads as "there is more below" at a glance.
const double kVisibleRowCount = 2.5;
// U+00D7 MULTIPLICATION SIGN, the close and delete glyph. Written as a code point
// because the sources are not guaranteed to be read as UTF-8 by every compiler.
const QChar kCrossGlyph(0x00D7);
// Inner size of a card's composed face: the name row, the contents row, and the
// cost row, plus the gap above the cost.
QSize getCardFaceSize(const QFont& font)
{
const int rowHeight = QFontMetrics(font).height();
return QSize(kCardWidthPx - 2 * kCardPaddingPx, rowHeight * 3 + kCardRowGapPx);
}
QSize getCardSize(const QFont& font)
{
const QSize faceSize = getCardFaceSize(font);
return QSize(faceSize.width() + 2 * kCardPaddingPx,
faceSize.height() + 2 * kCardPaddingPx);
}
// One card face: the blueprint name, its contents line below in a dimmed color,
// and the cost with its block icon at the bottom (REQ-UI-BLUEPRINT-CARD). The three
// are composed into a single pixmap because a QPushButton holds only one icon --
// the same device the build button faces use.
QPixmap composeCardFace(const QString& name, const QString& contents,
const QPixmap& cost, const QFont& font,
const QColor& textColor, const QColor& dimColor,
const QSize& faceSize)
{
const QFontMetrics metrics(font);
const int rowHeight = metrics.height();
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QPixmap face(static_cast<int>(faceSize.width() * dpr),
static_cast<int>(faceSize.height() * dpr));
face.setDevicePixelRatio(dpr);
face.fill(Qt::transparent);
QPainter painter(&face);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.setFont(font);
// Both text rows are elided rather than wrapped or shrunk, so a long name or a
// many-typed blueprint cannot change a card's height (REQ-UI-BLUEPRINT-CARD).
painter.setPen(textColor);
painter.drawText(QRect(0, 0, faceSize.width(), rowHeight),
Qt::AlignLeft | Qt::AlignVCenter,
metrics.elidedText(name, Qt::ElideRight, faceSize.width()));
painter.setPen(dimColor);
painter.drawText(QRect(0, rowHeight, faceSize.width(), rowHeight),
Qt::AlignLeft | Qt::AlignVCenter,
metrics.elidedText(contents, Qt::ElideRight, faceSize.width()));
const QSize costSize = getLogicalSize(cost);
painter.drawPixmap(0, faceSize.height() - costSize.height(), cost);
return face;
}
// A composed card face and the size to show it at; the button needs both, and only
// the composer knows the size it arrived at.
struct CardFace { QIcon icon; QSize size; };
// The two-mode face of one card. The modes differ only in the text color, so an
// unaffordable card greys itself when Qt swaps the pixmap on the disabled button
// (REQ-UI-BLUEPRINT-CARD, consistent with REQ-UI-BUILD-DISABLED).
CardFace buildCardFace(const QString& name, const QString& contents, int cost,
const QPixmap& blockIcon, const QFont& font,
const QPalette& palette)
{
const QSize faceSize = getCardFaceSize(font);
const QColor dimColor = palette.color(QPalette::Disabled, QPalette::ButtonText);
CardFace result;
for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled })
{
const QColor textColor = palette.color(
(mode == QIcon::Normal) ? QPalette::Active : QPalette::Disabled,
QPalette::ButtonText);
// The cost reads exactly like the header stock and the build button costs:
// the number, then the block icon in place of a trailing "Blocks"
// (REQ-UI-BLOCKS-ICON). A null icon leaves the bare number.
const QPixmap costPixmap =
renderCaptionWithIcon(QString::number(cost), blockIcon, font, textColor);
const QPixmap face = composeCardFace(name, contents, costPixmap, font,
textColor, dimColor, faceSize);
result.icon.addPixmap(face, mode);
result.size = result.size.expandedTo(getLogicalSize(face));
}
return result;
}
}
BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
ItemIconCache* itemIcons,
QWidget* parent)
: QDialog(parent)
, m_library(library)
, m_itemIcons(itemIcons)
{
setWindowTitle(tr("Blueprints"));
setModal(true);
// Frameless: the dialog draws its own header row, so an OS title bar would only
// repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
// rounding a top-level window needs a translucent background, which is unreliable
// on Windows. The border matches the build bar and the side panel.
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral(
"BlueprintSelectionDialog { background-color: palette(window);"
" border: 1px solid palette(mid); }"));
QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
mainLayout->setSpacing(kSpacingPx);
QHBoxLayout* headerLayout = new QHBoxLayout();
headerLayout->setSpacing(kSpacingPx);
QLabel* titleLabel = new QLabel(tr("Blueprints"), this);
QFont headerFont = titleLabel->font();
headerFont.setBold(true);
titleLabel->setFont(headerFont);
headerLayout->addWidget(titleLabel);
// Dimmed and bold, the treatment the build button hotkey badges use, so the
// shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST).
QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this);
hotkeyBadge->setFont(headerFont);
QPalette badgePalette = hotkeyBadge->palette();
badgePalette.setColor(hotkeyBadge->foregroundRole(),
palette().color(QPalette::Disabled, QPalette::WindowText));
hotkeyBadge->setPalette(badgePalette);
headerLayout->addWidget(hotkeyBadge);
headerLayout->addStretch();
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
closeButton->setToolTip(tr("Close"));
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
headerLayout->addWidget(closeButton);
mainLayout->addLayout(headerLayout);
QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->setFrameShape(QFrame::NoFrame);
m_gridContainer = new QWidget(scrollArea);
m_grid = new QGridLayout(m_gridContainer);
m_grid->setContentsMargins(0, 0, 0, 0);
m_grid->setSpacing(kSpacingPx);
m_grid->setAlignment(Qt::AlignTop | Qt::AlignLeft);
scrollArea->setWidget(m_gridContainer);
mainLayout->addWidget(scrollArea, 1);
rebuildGrid();
// Fixed size (REQ-UI-BLUEPRINT-DIALOG), sized to show kVisibleRowCount card rows.
const QSize cardSize = getCardSize(font());
const int gridWidth = kGridColumnCount * cardSize.width()
+ (kGridColumnCount - 1) * kSpacingPx;
const int gridHeight = static_cast<int>(
cardSize.height() * kVisibleRowCount + kSpacingPx * (kVisibleRowCount - 1.0));
setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent)
+ 2 * kSpacingPx,
gridHeight + kSmallButtonSizePx + 3 * kSpacingPx);
// A frameless dialog does not get Qt's automatic centering on its parent.
if (parent)
{
const QRect parentRect = parent->window()->geometry();
move(parentRect.center() - QPoint(width() / 2, height() / 2));
}
}
std::optional<int> BlueprintSelectionDialog::getChosenIndex() const
{
return m_chosenIndex;
}
void BlueprintSelectionDialog::rebuildGrid()
{
// deleteLater, not delete: this runs from a delete button's own clicked signal, and
// destroying that button inside its emission would leave Qt holding a dangling
// sender. hide() makes the removal immediate all the same.
while (QLayoutItem* item = m_grid->takeAt(0))
{
if (item->widget())
{
item->widget()->hide();
item->widget()->deleteLater();
}
delete item;
}
const std::vector<Blueprint>& blueprints = m_library->getBlueprints();
if (blueprints.empty())
{
QLabel* emptyLabel = new QLabel(
tr("No blueprints yet.\n\nSelect one or more buildings and press Ctrl+C "
"to save them as a blueprint."),
m_gridContainer);
emptyLabel->setAlignment(Qt::AlignCenter);
emptyLabel->setWordWrap(true);
m_grid->addWidget(emptyLabel, 0, 0, 1, kGridColumnCount);
return;
}
// Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); 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();
const QSize cardSize = getCardSize(font());
for (int i = 0; i < static_cast<int>(blueprints.size()); ++i)
{
const Blueprint& blueprint = blueprints[static_cast<std::size_t>(i)];
const CardFace face = buildCardFace(
blueprint.name, m_library->getContentsSummary(i), m_library->getCost(i),
blockIcon, font(), palette());
QWidget* card = new QWidget(m_gridContainer);
card->setFixedSize(cardSize);
QVBoxLayout* cardLayout = new QVBoxLayout(card);
cardLayout->setContentsMargins(0, 0, 0, 0);
QPushButton* body = new QPushButton(card);
body->setIcon(face.icon);
body->setIconSize(face.size);
// An unaffordable card is greyed and does not respond, but its delete icon
// stays live (REQ-UI-BLUEPRINT-CARD).
body->setEnabled(m_library->getCanAfford(i));
cardLayout->addWidget(body);
// A sibling of the body rather than one of its children, and outside the
// layout: Qt disables a widget's children along with it, and the delete icon
// must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE).
QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card);
deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
deleteButton->setToolTip(tr("Delete blueprint"));
deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2,
cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2);
deleteButton->raise();
m_grid->addWidget(card, i / kGridColumnCount, i % kGridColumnCount);
const int index = i;
connect(body, &QPushButton::clicked, this, [this, index]()
{
onCardClicked(index);
});
connect(deleteButton, &QPushButton::clicked, this, [this, index]()
{
onDeleteClicked(index);
});
}
}
void BlueprintSelectionDialog::onCardClicked(int index)
{
// Placement mode is entered by the caller once the dialog has closed
// (REQ-UI-BLUEPRINT-CARD).
m_chosenIndex = index;
accept();
}
void BlueprintSelectionDialog::onDeleteClicked(int index)
{
// No confirmation prompt, and the dialog stays open (REQ-UI-BLUEPRINT-DELETE).
m_library->remove(index);
rebuildGrid();
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include <optional>
#include <QDialog>
class BlueprintLibrary;
class ItemIconCache;
class QGridLayout;
class QWidget;
// The blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG): a frameless modal panel
// showing every saved blueprint as a card in a scrolling two-column grid. The caller
// pauses the game and raises the dim overlay while it is open.
//
// Clicking a card accepts the dialog and reports that blueprint's index; the caller
// enters placement mode afterwards, so the dialog is already closed by then
// (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the
// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape and the close button dismiss it with
// no other effect.
class BlueprintSelectionDialog : public QDialog
{
Q_OBJECT
public:
// Neither the library nor the icon cache is owned; both outlive the dialog.
BlueprintSelectionDialog(BlueprintLibrary* library, ItemIconCache* itemIcons,
QWidget* parent = nullptr);
std::optional<int> getChosenIndex() const;
private:
void rebuildGrid();
void onCardClicked(int index);
void onDeleteClicked(int index);
BlueprintLibrary* m_library;
ItemIconCache* m_itemIcons;
QWidget* m_gridContainer;
QGridLayout* m_grid;
std::optional<int> m_chosenIndex; // nullopt = dismissed without picking
};

View File

@@ -96,16 +96,6 @@ namespace
return result; return result;
} }
// A pixmap's size in device-independent pixels. The pixmaps composed here are
// rasterized at the device pixel ratio, so their raw size is not their layout size.
QSize getLogicalSize(const QPixmap& pixmap)
{
if (pixmap.isNull()) { return QSize(0, 0); }
const qreal dpr = pixmap.devicePixelRatio();
return QSize(static_cast<int>(pixmap.width() / dpr),
static_cast<int>(pixmap.height() / dpr));
}
// One button face: the hotkey badge in the top-left corner, the chip icon centered // 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 // 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 // bottom (REQ-UI-BUILD-COST). The three are composed into a single pixmap because

View File

@@ -13,7 +13,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
@@ -38,7 +39,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp

View File

@@ -46,3 +46,11 @@ QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
return pixmap; return pixmap;
} }
QSize getLogicalSize(const QPixmap& pixmap)
{
if (pixmap.isNull()) { return QSize(0, 0); }
const qreal dpr = pixmap.devicePixelRatio();
return QSize(static_cast<int>(pixmap.width() / dpr),
static_cast<int>(pixmap.height() / dpr));
}

View File

@@ -3,6 +3,7 @@
#include <QColor> #include <QColor>
#include <QFont> #include <QFont>
#include <QPixmap> #include <QPixmap>
#include <QSize>
#include <QString> #include <QString>
// Renders `text` followed by `icon` to its right, vertically centered, onto a // Renders `text` followed by `icon` to its right, vertically centered, onto a
@@ -15,6 +16,11 @@
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon, QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
const QFont& font, const QColor& textColor); const QFont& font, const QColor& textColor);
// A pixmap's size in device-independent pixels. The pixmaps composed by
// renderCaptionWithIcon and its callers are rasterized at the device pixel ratio, so
// their raw size is not the size to lay them out at.
QSize getLogicalSize(const QPixmap& pixmap);
// Item id of the building blocks resource, whose icon stands in for the "Blocks" // Item id of the building blocks resource, whose icon stands in for the "Blocks"
// word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST, // word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
// REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of // REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of

View File

@@ -5,6 +5,8 @@
#include <QKeyEvent> #include <QKeyEvent>
#include "BlueprintSaveRequestedEvent.h"
#include "BlueprintSelectionRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h" #include "BuildHotkeyPressedEvent.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "DebugDrawToggleRequestedEvent.h" #include "DebugDrawToggleRequestedEvent.h"
@@ -97,6 +99,27 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
} }
} }
// Blueprint chords (REQ-UI-HOTKEYS). Checked ahead of the plain-key switch below,
// which binds bare A/D/W/S/R/Q/T and must not fire on a Ctrl chord. Both requests
// are decided by MainWindow, the only widget that can pause the game and dim the
// window for a modal.
if ((event->modifiers() & Qt::ControlModifier) != 0)
{
switch (event->key())
{
case Qt::Key_C:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSaveRequestedEvent>());
return true;
case Qt::Key_V:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSelectionRequestedEvent>());
return true;
default:
break;
}
}
switch (event->key()) switch (event->key())
{ {
case Qt::Key_A: case Qt::Key_A:
@@ -131,7 +154,7 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
return true; return true;
case Qt::Key_T: case Qt::Key_T:
// Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP). // Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
// The BlueprintPanel owns the selection and blueprint-capture logic; it decides // The BlueprintLibrary owns the selection and blueprint-capture logic; it decides
// whether anything placeable is selected and drives placement mode from there. // whether anything placeable is selected and drives placement mode from there.
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintRequestedEvent>()); std::make_shared<TemporaryBlueprintRequestedEvent>());

View File

@@ -9,12 +9,15 @@
#include <QCloseEvent> #include <QCloseEvent>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QInputDialog>
#include <QLineEdit>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QResizeEvent> #include <QResizeEvent>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "BlueprintPanel.h" #include "BlueprintLibrary.h"
#include "BlueprintSelectionDialog.h"
#include "BuildButtonBar.h" #include "BuildButtonBar.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "Command.h" #include "Command.h"
@@ -71,31 +74,29 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir, m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir,
m_itemIcons.get(), this); m_itemIcons.get(), this);
// The blueprints have no widget of their own: they are saved with Ctrl+C and picked
// from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window
// because only it can pause the game and raise the dim overlay. Built after the
// world view because loading blueprints.toml may put a message box on screen.
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
m_sidePanel = new QWidget(this); m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
sideLayout->setContentsMargins(1, 1, 1, 1); sideLayout->setContentsMargins(1, 1, 1, 1);
sideLayout->setSpacing(1); sideLayout->setSpacing(1);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel); // The selected building panel is the column's only panel and fills its height
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
// Equal stretch gives the two panels half the column height each
// (REQ-UI-PANEL-COLUMN). // (REQ-UI-PANEL-COLUMN).
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1); sideLayout->addWidget(m_selectedBuildingPanel, 1);
sideLayout->addWidget(m_blueprintPanel, 1);
// Draw a thin border around each of the two side-panel sections. The class // Draw a thin border around the side panel section. The class scoped selector keeps
// scoped selectors keep the border on the panels themselves rather than // the border on the panel itself rather than cascading onto its child widgets;
// cascading onto their child widgets; WA_StyledBackground lets the plain // WA_StyledBackground lets the plain QWidget subclass honor the stylesheet box
// QWidget subclasses honor the stylesheet box (border/background). // (border/background).
for (QWidget* panel : { static_cast<QWidget*>(m_selectedBuildingPanel), m_selectedBuildingPanel->setAttribute(Qt::WA_StyledBackground, true);
static_cast<QWidget*>(m_blueprintPanel) })
{
panel->setAttribute(Qt::WA_StyledBackground, true);
}
m_sidePanel->setStyleSheet(QStringLiteral( m_sidePanel->setStyleSheet(QStringLiteral(
"SelectedBuildingPanel, BlueprintPanel {" "SelectedBuildingPanel { border: 1px solid palette(mid); }"));
" border: 1px solid palette(mid); }"));
// Created last so it stacks above the other children; covers the whole window and // Created last so it stacks above the other children; covers the whole window and
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM). // dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
@@ -369,6 +370,47 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
} }
} }
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> /*event*/)
{
// Ctrl+C has effect only when something player-placeable is selected; otherwise no
// dialog opens at all (REQ-UI-BLUEPRINT-CREATE).
if (!m_blueprintLibrary->getCanCaptureSelection()) { return; }
// Both scopes are held across the naming dialog and the selection dialog it hands
// over to, so the dim never blinks off and the simulation is not resumed in between
// (REQ-UI-MODAL-DIM).
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
bool ok = false;
const QString name = QInputDialog::getText(
this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal,
QString(), &ok);
// Cancel, Escape, or an empty name: no blueprint, and no selection dialog.
if (!ok || name.trimmed().isEmpty()) { return; }
m_blueprintLibrary->saveSelectionAs(name.trimmed());
showBlueprintSelectionDialog();
}
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/)
{
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
showBlueprintSelectionDialog();
}
void MainWindow::showBlueprintSelectionDialog()
{
BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(), this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenIndex().has_value())
{
// Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD
// describes: clicking a card closes the dialog and enters placement mode.
m_blueprintLibrary->beginPlacement(*dialog.getChosenIndex());
}
}
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/) void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
{ {
const Tick tick = m_sim->getCurrentTick(); const Tick tick = m_sim->getCurrentTick();

View File

@@ -7,6 +7,8 @@
#include <QWidget> #include <QWidget>
#include "BlueprintSaveRequestedEvent.h"
#include "BlueprintSelectionRequestedEvent.h"
#include "BuildingId.h" #include "BuildingId.h"
#include "EscapeMenuRequestedEvent.h" #include "EscapeMenuRequestedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
@@ -28,7 +30,7 @@ class GameWorldView;
class HeaderBar; class HeaderBar;
class SelectedBuildingPanel; class SelectedBuildingPanel;
class BuildButtonBar; class BuildButtonBar;
class BlueprintPanel; class BlueprintLibrary;
class ItemIconCache; class ItemIconCache;
class QCloseEvent; class QCloseEvent;
class QResizeEvent; class QResizeEvent;
@@ -39,7 +41,9 @@ class MainWindow : public QWidget,
WinEvent, WinEvent,
EscapeMenuRequestedEvent, EscapeMenuRequestedEvent,
LayoutDialogRequestedEvent, LayoutDialogRequestedEvent,
RecipeSelectionRequestedEvent> RecipeSelectionRequestedEvent,
BlueprintSaveRequestedEvent,
BlueprintSelectionRequestedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -59,6 +63,8 @@ private:
void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override; void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override; void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override; void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override;
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared // Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
// by every restart path. On success the reloaded visuals are applied to this // by every restart path. On success the reloaded visuals are applied to this
@@ -71,6 +77,12 @@ private:
void openShipLayoutDialog(BuildingId shipyardId, void openShipLayoutDialog(BuildingId shipyardId,
const std::string& schematicId, const std::string& schematicId,
const ShipLayoutConfig& currentLayout); const ShipLayoutConfig& currentLayout);
// Runs the blueprint selection dialog and enters placement mode for whatever the
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own:
// both callers already hold theirs, which is what keeps the dim continuous when a
// confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM).
void showBlueprintSelectionDialog();
void layoutPanels(); void layoutPanels();
private: private:
@@ -84,7 +96,9 @@ private:
HeaderBar* m_headerBar; HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel; SelectedBuildingPanel* m_selectedBuildingPanel;
BuildButtonBar* m_buildButtonBar; BuildButtonBar* m_buildButtonBar;
BlueprintPanel* m_blueprintPanel; // The saved blueprints themselves; they have no widget of their own any more and
// are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
std::unique_ptr<BlueprintLibrary> m_blueprintLibrary;
QWidget* m_sidePanel; QWidget* m_sidePanel;
ModalDimOverlay* m_dimOverlay = nullptr; ModalDimOverlay* m_dimOverlay = nullptr;