2 Commits

43 changed files with 732 additions and 410 deletions

View File

@@ -483,9 +483,10 @@ The screen is divided into two columns: a main column (75% width) containing the
### Selected Building Panel
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or scrap pile), the panel is empty.
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with scrap piles). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus scrap piles, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident scrap pile (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-SCRAP-CLICK-SELECT).
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
- REQ-UI-MULTI-SELECT: The player selects multiple buildings by box-drag or by Ctrl+clicking individual buildings to add or remove them from the selection. A box-drag that covers at least one building selects buildings (any scrap piles within the box are ignored); a box-drag that covers no buildings but does cover scrap piles selects those scrap piles instead (REQ-UI-SCRAP-MULTI-SELECT).
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or scrap piles selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button grid); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
@@ -493,8 +494,8 @@ The screen is divided into two columns: a main column (75% width) containing the
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. Clicking a ship or defence station clears any existing selection and establishes a single-entity selection containing only that entity. Ships and defence stations cannot participate in multi-select together with buildings. Clicking a scrap pile instead establishes a scrap selection (REQ-UI-SCRAP-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or scrap pile) clears the selection.
- REQ-UI-SHIP-STATS-PANEL: When a single ship is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **ship stats panel**. The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with scrap piles and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a scrap pile adds to or establishes a field selection (REQ-UI-SCRAP-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or scrap pile) clears the selection.
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no scrap is selected, the selected building panel shows a **ship stats panel**. (If scrap is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
- **Retreating** — the ship is retreating (REQ-SHP-RETREAT).
- **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI).
@@ -503,10 +504,11 @@ The screen is divided into two columns: a main column (75% width) containing the
- **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY).
- **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY).
- **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid).
- REQ-UI-STATION-STATS-PANEL: When a single defence station is selected (REQ-UI-ENTITY-CLICK-SELECT), the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate.
- REQ-UI-SCRAP-CLICK-SELECT: The player can click any scrap pile (REQ-RES-SCRAP-DROP) in the game world to select it. Scrap forms its own selection category: clicking a scrap pile clears any existing selection and establishes a scrap selection containing only that pile. Scrap piles cannot participate in a selection together with buildings, ships, or defence stations. Hit-testing prioritizes actors over scrap: when a building, ship, or defence station lies under the cursor at the same point as a scrap pile, that object is selected in preference to the scrap; a scrap pile is selected only when no building, ship, or defence station is under the cursor. A selected scrap pile that despawns or is fully collected (REQ-RES-SCRAP-DROP) is removed from the selection; once the last selected pile is gone, the selection becomes empty (REQ-UI-EMPTY-SELECTION).
- REQ-UI-SCRAP-MULTI-SELECT: Multiple scrap piles can be selected by box-drag or by Ctrl+clicking individual piles to add or remove them from the selection, mirroring building multi-select (REQ-UI-MULTI-SELECT). A scrap selection contains only scrap piles. Because scrap cannot mix with other object types (REQ-UI-SCRAP-CLICK-SELECT), Ctrl+clicking a scrap pile while a building, ship, or defence station selection is active first clears that selection and begins a scrap selection; conversely, selecting a building, ship, or defence station while a scrap selection is active clears the scrap selection. Box-drag disambiguation between buildings and scrap follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering scrap but no buildings selects the scrap).
- REQ-UI-SCRAP-PANEL: When one or more scrap piles are selected, the selected building panel shows the **total remaining scrap amount** across all selected piles — the sum of the piles' current remaining amounts (REQ-RES-SCRAP-DROP), e.g. "Scrap: 47". The same summed-amount display is used whether one pile or many are selected; no per-pile detail and no pile count are shown. The displayed total updates as selected piles are partially collected or despawn (REQ-UI-SCRAP-CLICK-SELECT).
- REQ-UI-STATION-STATS-PANEL: When exactly one defence station is selected (REQ-UI-ENTITY-CLICK-SELECT) and no scrap is selected, the selected building panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate. (If scrap is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.)
- REQ-UI-FIELD-MULTI-SELECTION: The full single-actor stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL) is shown only when the field selection holds exactly one actor and no scrap. Whenever the selection holds more than one object — multiple actors, or a single actor together with scrap — the panel shows a **compact summary** instead: a count per actor type, one line per type rendered as "<type> x <count>" (the same `x`-count notation as the recipe tooltip and the building multi-selection, REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy. No per-actor detail and no total-actor-count header are shown (consistent with the building panel). If scrap piles are also part of the field selection (REQ-UI-SELECTION-CATEGORIES) their total is appended as a final line of the same summary (REQ-UI-SCRAP-PANEL), so all lines share uniform spacing. Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead.
- REQ-UI-SCRAP-CLICK-SELECT: The player can click any scrap pile (REQ-RES-SCRAP-DROP) in the game world to select it. Scrap piles are field objects (REQ-UI-SELECTION-CATEGORIES) and can be selected together with ships and defence stations, but not with buildings. A plain click on a scrap pile makes it the sole selection, clearing any previous selection; selecting a building clears any scrap (buildings win), and selecting a scrap pile clears any building selection. Hit-testing prefers a building over a coincident actor or scrap pile, and an actor (ship or defence station) over a coincident scrap pile: a scrap pile is selected only when no building or actor is under the cursor. A selected scrap pile that despawns or is fully collected (REQ-RES-SCRAP-DROP) is removed from the selection; if no selected object remains, the panel becomes empty (REQ-UI-EMPTY-SELECTION).
- REQ-UI-SCRAP-MULTI-SELECT: Multiple scrap piles can be selected by box-drag or by Ctrl+clicking individual piles to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Scrap shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold scrap piles and actors together. Ctrl+clicking a scrap pile while a field selection is active adds or removes that pile within the same selection; Ctrl+clicking a scrap pile while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and scrap piles within it).
- REQ-UI-SCRAP-PANEL: When one or more scrap piles are selected, the selected building panel shows the **total remaining scrap amount** across all selected piles — the sum of the piles' current remaining amounts (REQ-RES-SCRAP-DROP), e.g. "Scrap x 47". The same summed-amount display is used whether one pile or many are selected; no per-pile detail and no pile count are shown. The displayed total updates as selected piles are partially collected or despawn (REQ-UI-SCRAP-CLICK-SELECT). When actors are also selected, this scrap total is shown as an additional line of the actor count summary rather than alongside a single-actor stats panel (REQ-UI-FIELD-MULTI-SELECTION).
### Build Button Grid

View File

@@ -44,7 +44,6 @@ ArenaSimulation::ArenaSimulation(const GameConfig& gameConfig,
, m_team1HqEntity(entt::null)
, m_team2HqEntity(entt::null)
, m_finished(false)
, m_winnerTeam(-1)
, m_stopRequested(false)
{
m_buildingSystem = std::make_unique<BuildingSystem>(
@@ -473,7 +472,7 @@ bool ArenaSimulation::isFinished() const
return m_finished;
}
int ArenaSimulation::getWinnerTeam() const
std::optional<int> ArenaSimulation::getWinnerTeam() const
{
return m_winnerTeam;
}

View File

@@ -58,7 +58,7 @@ struct ArenaStatus
TeamStatus teams[2];
bool finished = false;
int winnerTeam = -1; // 0 or 1 when finished; -1 while running
std::optional<int> winnerTeam; // 0 or 1 when finished; nullopt while running
// Game time the fight has lasted (simulated ticks * fixed tick duration).
// Meaningful once finished; the battle duration shown for completed runs.
double durationSeconds = 0.0;
@@ -80,7 +80,7 @@ public:
ArenaStatus getStatus() const;
bool isFinished() const;
int getWinnerTeam() const;
std::optional<int> getWinnerTeam() const;
Tick getCurrentTick() const;
const ArenaConfig& getArenaConfig() const;
@@ -122,7 +122,7 @@ private:
entt::entity m_team2HqEntity;
bool m_finished;
int m_winnerTeam;
std::optional<int> m_winnerTeam;
std::atomic<bool> m_stopRequested;
// Static accumulated threat per team, computed once from the configured roster.

View File

@@ -15,7 +15,7 @@
#include "Building.h"
#include "BuildingSystem.h"
#include "EntityHitTest.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "FacingComponent.h"
#include "FactionComponent.h"
@@ -259,8 +259,14 @@ void ArenaView::mousePressEvent(QMouseEvent* event)
m_selectedEntity = std::nullopt;
}
// The arena is strictly single-select; emit a vector of size 0 or 1.
std::vector<entt::entity> selection;
if (m_selectedEntity.has_value())
{
selection.push_back(*m_selectedEntity);
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectedEvent>(m_selectedEntity));
std::make_shared<EntitySelectionChangedEvent>(selection));
}
QOpenGLWidget::mousePressEvent(event);

View File

@@ -13,7 +13,7 @@
#include "BeamFiredEvent.h"
#include "entt/entity/entity.hpp"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "Tick.h"
#include "TickDriver.h"
#include "VisualsConfig.h"

View File

@@ -76,7 +76,6 @@ BalancingWindow::BalancingWindow(const BalancingConfig& balancingConfig,
, m_balancingConfigPath(balancingConfigPath)
, m_nextSeed(0)
, m_inspectWindow(nullptr)
, m_inspectedArenaIndex(-1)
{
m_visuals = VisualsLoader::load(m_configDir + "/visuals.toml");
setWindowTitle(tr("DotaFactory — Balancing Tool"));
@@ -184,10 +183,10 @@ void BalancingWindow::pollStatuses()
}
}
if (m_inspectedSim && m_inspectedArenaIndex >= 0)
if (m_inspectedSim && m_inspectedArenaIndex.has_value())
{
const ArenaStatus status = m_inspectedSim->getStatus();
m_arenas[static_cast<std::size_t>(m_inspectedArenaIndex)].widget->updateStatus(status);
m_arenas[static_cast<std::size_t>(*m_inspectedArenaIndex)].widget->updateStatus(status);
}
updateButtons();
@@ -255,13 +254,13 @@ void BalancingWindow::inspectArena(int index)
delete m_inspectWindow;
m_inspectWindow = nullptr;
if (m_inspectedSim && m_inspectedArenaIndex >= 0
if (m_inspectedSim && m_inspectedArenaIndex.has_value()
&& !m_inspectedSim->isFinished())
{
m_arenas[static_cast<std::size_t>(m_inspectedArenaIndex)].widget->resetToGrey();
m_arenas[static_cast<std::size_t>(*m_inspectedArenaIndex)].widget->resetToGrey();
}
m_inspectedSim.reset();
m_inspectedArenaIndex = -1;
m_inspectedArenaIndex = std::nullopt;
}
ArenaEntry& entry = m_arenas[static_cast<std::size_t>(index)];
@@ -297,16 +296,16 @@ void BalancingWindow::closeInspectWindow()
m_inspectWindow->deleteLater();
m_inspectWindow = nullptr;
if (m_inspectedArenaIndex >= 0 && m_inspectedSim)
if (m_inspectedArenaIndex.has_value() && m_inspectedSim)
{
if (!m_inspectedSim->isFinished())
{
m_arenas[static_cast<std::size_t>(m_inspectedArenaIndex)].widget->resetToGrey();
m_arenas[static_cast<std::size_t>(*m_inspectedArenaIndex)].widget->resetToGrey();
}
}
m_inspectedSim.reset();
m_inspectedArenaIndex = -1;
m_inspectedArenaIndex = std::nullopt;
setMainControlsEnabled(true);
updateButtons();
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <vector>
@@ -78,6 +79,6 @@ private:
QTimer* m_pollTimer;
InspectWindow* m_inspectWindow;
int m_inspectedArenaIndex;
std::optional<int> m_inspectedArenaIndex; // nullopt = no arena inspected
std::unique_ptr<ArenaSimulation> m_inspectedSim;
};

View File

@@ -250,11 +250,12 @@ void InspectWindow::updateInfoPanel(const ArenaStatus& status)
}
}
void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectedEvent> event)
void InspectWindow::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
{
if (event->entity.has_value())
if (!event->entities.empty())
{
m_selectedEntity = event->entity;
// The arena is single-select, so only the first entity is inspected.
m_selectedEntity = event->entities.front();
EntityAdmin& admin = m_sim->getAdmin();
entt::entity entity = *m_selectedEntity;

View File

@@ -12,7 +12,7 @@
#include "entt/entity/entity.hpp"
#include "ArenaSimulation.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "GameSpeedChangedEvent.h"
@@ -22,7 +22,7 @@ class ArenaView;
class ShipStatsPanel;
class InspectWindow : public QWidget,
public CombinedEventHandler<EntitySelectedEvent,
public CombinedEventHandler<EntitySelectionChangedEvent,
GameSpeedChangedEvent>
{
Q_OBJECT
@@ -38,7 +38,7 @@ protected:
void keyPressEvent(QKeyEvent* event) override;
private:
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const GameSpeedChangedEvent> event) override;
private slots:

View File

@@ -1,5 +1,7 @@
#pragma once
#include <optional>
#include "BuildingId.h"
// Deliver-scrap behavior (one half of the old SalvageBehaviorComponent). Scored
@@ -7,6 +9,6 @@
// SalvagerSystem performs the actual delivery.
struct DeliverScrapBehavior
{
BuildingId deliveryBay = kInvalidBuildingId;
std::optional<BuildingId> deliveryBay; // nullopt until a bay is assigned
float score = 0.0f;
};

View File

@@ -88,8 +88,8 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
m_admin.forEach<DeliverScrapBehavior, PositionComponent>(
[&](entt::entity ship, const DeliverScrapBehavior& deliver, const PositionComponent& pos)
{
if (deliver.deliveryBay == kInvalidBuildingId) { return; }
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
if (!deliver.deliveryBay.has_value()) { return; }
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
if (!bay) { return; }
const QVector2D bayCenter(bay->anchor.x() + bay->footprint.width() / 2.0f,
@@ -100,7 +100,7 @@ void SalvagerSystem::tick(Tick currentTick, ScrapSystem& scraps, BuildingSystem&
if (!m_admin.hasAll<CargoComponent>(ship)) { return; }
CargoComponent& cargo = m_admin.get<CargoComponent>(ship);
if (cargo.current <= 0) { return; }
if (buildings.deliverScrapToSalvageBay(deliver.deliveryBay))
if (buildings.deliverScrapToSalvageBay(*deliver.deliveryBay))
{
--cargo.current;
}

View File

@@ -398,8 +398,7 @@ entt::entity ShipSystem::spawn(const std::string& schematicId,
maxCollRange * static_cast<float>(m_config.world.orbitFactor);
m_admin.addComponent<SalvageScrapBehavior>(entity, salvage);
DeliverScrapBehavior deliver;
deliver.deliveryBay = kInvalidBuildingId;
DeliverScrapBehavior deliver; // deliveryBay starts unassigned (nullopt)
m_admin.addComponent<DeliverScrapBehavior>(entity, deliver);
}

View File

@@ -31,7 +31,7 @@ void DeliverScrapEvaluator::evaluate(EntityAdmin& admin, const BuildingSystem& b
}
// Assign nearest SalvageBay if not yet assigned.
if (deliver.deliveryBay == kInvalidBuildingId)
if (!deliver.deliveryBay.has_value())
{
const Building* bay =
buildings.findNearestBuilding(pos.value, BuildingType::SalvageBay);

View File

@@ -24,9 +24,9 @@ void DeliverScrapExecutor::execute(EntityAdmin& admin, const BuildingSystem& bui
if (selected.winner != BehaviorKind::DeliverScrap) { return; }
QVector2D dest = pos.value;
if (deliver.deliveryBay != kInvalidBuildingId)
if (deliver.deliveryBay.has_value())
{
const Building* bay = buildings.findBuilding(deliver.deliveryBay);
const Building* bay = buildings.findBuilding(*deliver.deliveryBay);
if (bay)
{
dest = QVector2D(bay->anchor.x() + bay->footprint.width() / 2.0f,

View File

@@ -4,7 +4,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/TickAdvancedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBlocksChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/ExpansionCostChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EntitySelectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameSpeedChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h

View File

@@ -1,21 +0,0 @@
#ifndef ENTITY_SELECTED_EVENT_H
#define ENTITY_SELECTED_EVENT_H
#include <optional>
#include "entt/entity/entity.hpp"
#include "Event.h"
class EntitySelectedEvent : public Event
{
public:
explicit EntitySelectedEvent(std::optional<entt::entity> entity)
: entity(entity)
{
}
const std::optional<entt::entity> entity;
};
#endif // ENTITY_SELECTED_EVENT_H

View File

@@ -0,0 +1,25 @@
#ifndef ENTITY_SELECTION_CHANGED_EVENT_H
#define ENTITY_SELECTION_CHANGED_EVENT_H
#include <vector>
#include "entt/entity/entity.hpp"
#include "Event.h"
// The set of currently selected ships and/or defence stations. An empty list means
// no actor is selected. Actors share the "field" selection category with scrap piles
// (REQ-UI-SELECTION-CATEGORIES): they can be selected together, but never together with
// buildings.
class EntitySelectionChangedEvent : public Event
{
public:
explicit EntitySelectionChangedEvent(std::vector<entt::entity> entities)
: entities(std::move(entities))
{
}
const std::vector<entt::entity> entities;
};
#endif // ENTITY_SELECTION_CHANGED_EVENT_H

View File

@@ -346,7 +346,7 @@ std::vector<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe
// Placement
// ---------------------------------------------------------------------------
BuildingId BuildingSystem::place(BuildingType type, QPoint anchor,
std::optional<BuildingId> BuildingSystem::place(BuildingType type, QPoint anchor,
Rotation rotation, Tick currentTick)
{
const BuildingDef* def = findBuildingDef(type);
@@ -356,7 +356,7 @@ BuildingId BuildingSystem::place(BuildingType type, QPoint anchor,
// Reject placements that fall outside the world (REQ-BLD-PLACE-VALID).
if (!bodyCellsWithinWorldBounds(mask.bodyCells, anchor))
{
return kInvalidBuildingId;
return std::nullopt;
}
const BuildingId id = m_allocateBuildingId();

View File

@@ -43,13 +43,13 @@ public:
std::mt19937& rng);
// -- Placement / demolish ------------------------------------------------
// Returns the new entity id, or kInvalidBuildingId if the placement falls
// outside the world bounds (vertical extent and asteroid left edge). Belt
// and Splitter register with BeltSystem directly; other types enter the
// construction queue. Terrain type (A vs S) is NOT checked here so that
// tests can stage arbitrary layouts; the player-facing entry point
// Returns the new entity id, or nullopt if the placement falls outside the
// world bounds (vertical extent and asteroid left edge). Belt and Splitter
// register with BeltSystem directly; other types enter the construction
// queue. Terrain type (A vs S) is NOT checked here so that tests can stage
// arbitrary layouts; the player-facing entry point
// (Simulation::tryPlaceBuilding) enforces the full rule via isPlacementValid.
BuildingId place(BuildingType type, QPoint anchor, Rotation rotation,
std::optional<BuildingId> place(BuildingType type, QPoint anchor, Rotation rotation,
Tick currentTick);
// Returns true if the placement satisfies REQ-BLD-PLACE-VALID terrain and

View File

@@ -75,27 +75,27 @@ struct PlaceBuildingCommand : Command
struct DemolishCommand : Command
{
DemolishCommand() : Command(CommandKind::Demolish) {}
BuildingId id = kInvalidBuildingId;
std::optional<BuildingId> id;
};
struct RotateInPlaceCommand : Command
{
RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {}
BuildingId id = kInvalidBuildingId;
std::optional<BuildingId> id;
Rotation newRotation = Rotation::East;
};
struct SetRecipeCommand : Command
{
SetRecipeCommand() : Command(CommandKind::SetRecipe) {}
BuildingId id = kInvalidBuildingId;
std::optional<BuildingId> id;
std::string recipeId;
};
struct SetShipLayoutCommand : Command
{
SetShipLayoutCommand() : Command(CommandKind::SetShipLayout) {}
BuildingId id = kInvalidBuildingId;
std::optional<BuildingId> id;
ShipLayoutConfig layout;
};
@@ -104,7 +104,7 @@ struct SetShipLayoutCommand : Command
struct SetSiteSplitterFiltersCommand : Command
{
SetSiteSplitterFiltersCommand() : Command(CommandKind::SetSiteSplitterFilters) {}
BuildingId id = kInvalidBuildingId;
std::optional<BuildingId> id;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};

View File

@@ -129,24 +129,24 @@ std::string serializeCommand(const Command& command)
break;
}
case CommandKind::Demolish:
out << "demolish " << static_cast<const DemolishCommand&>(command).id;
out << "demolish " << static_cast<const DemolishCommand&>(command).id.value();
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation);
out << "rotate " << c.id.value() << ' ' << rotationToChar(c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
out << "setrecipe " << c.id << ' ' << c.recipeId;
out << "setrecipe " << c.id.value() << ' ' << c.recipeId;
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
out << "setlayout " << c.id << ' ';
out << "setlayout " << c.id.value() << ' ';
appendLayout(out, c.layout);
break;
}
@@ -154,7 +154,7 @@ std::string serializeCommand(const Command& command)
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
out << "sitefilters " << c.id << ' ';
out << "sitefilters " << c.id.value() << ' ';
appendFilters(out, c.filterA, c.filterB);
break;
}
@@ -242,27 +242,35 @@ std::shared_ptr<Command> parseCommand(const std::string& tokens)
if (verb == "demolish")
{
std::shared_ptr<DemolishCommand> c = std::make_shared<DemolishCommand>();
if (!(in >> c->id)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id)) { return nullptr; }
c->id = id;
return c;
}
if (verb == "rotate")
{
std::shared_ptr<RotateInPlaceCommand> c = std::make_shared<RotateInPlaceCommand>();
std::string rotToken;
if (!(in >> c->id >> rotToken)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id >> rotToken)) { return nullptr; }
c->id = id;
c->newRotation = rotationFromString(rotToken);
return c;
}
if (verb == "setrecipe")
{
std::shared_ptr<SetRecipeCommand> c = std::make_shared<SetRecipeCommand>();
if (!(in >> c->id >> c->recipeId)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id >> c->recipeId)) { return nullptr; }
c->id = id;
return c;
}
if (verb == "setlayout")
{
std::shared_ptr<SetShipLayoutCommand> c = std::make_shared<SetShipLayoutCommand>();
if (!(in >> c->id)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id)) { return nullptr; }
c->id = id;
c->layout = parseLayout(in, ok);
if (!ok) { return nullptr; }
return c;
@@ -271,7 +279,9 @@ std::shared_ptr<Command> parseCommand(const std::string& tokens)
{
std::shared_ptr<SetSiteSplitterFiltersCommand> c =
std::make_shared<SetSiteSplitterFiltersCommand>();
if (!(in >> c->id)) { return nullptr; }
BuildingId id = 0;
if (!(in >> id)) { return nullptr; }
c->id = id;
parseFilters(in, c->filterA, c->filterB, ok);
if (!ok) { return nullptr; }
return c;

View File

@@ -6,6 +6,7 @@
#include "EntityAdmin.h"
#include "PositionComponent.h"
#include "ScrapDataComponent.h"
#include "ShipIdentityComponent.h"
#include "StationBodyComponent.h"
#include "HealthComponent.h"
@@ -101,3 +102,46 @@ std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint ti
});
return result;
}
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB)
{
const int minX = std::min(tileA.x(), tileB.x());
const int maxX = std::max(tileA.x(), tileB.x());
const int minY = std::min(tileA.y(), tileB.y());
const int maxY = std::max(tileA.y(), tileB.y());
std::vector<entt::entity> result;
// Stations: included when any occupied body cell lies in the box.
admin.forEach<StationBodyComponent, HealthComponent>(
[&](entt::entity entity, const StationBodyComponent& sb, const HealthComponent& h)
{
if (h.hp <= 0.0f) { return; }
for (const QPoint& cell : sb.bodyCells)
{
if (cell.x() >= minX && cell.x() <= maxX
&& cell.y() >= minY && cell.y() <= maxY)
{
result.push_back(entity);
return;
}
}
});
// Ships: included when the floored position tile lies in the box. Requiring
// ShipIdentityComponent excludes the HQ proxy and any station bodies.
admin.forEach<ShipIdentityComponent, PositionComponent, HealthComponent>(
[&](entt::entity entity, const ShipIdentityComponent& /*id*/,
const PositionComponent& pos, const HealthComponent& h)
{
if (h.hp <= 0.0f) { return; }
const int tileX = static_cast<int>(std::floor(pos.value.x()));
const int tileY = static_cast<int>(std::floor(pos.value.y()));
if (tileX >= minX && tileX <= maxX && tileY >= minY && tileY <= maxY)
{
result.push_back(entity);
}
});
return result;
}

View File

@@ -19,3 +19,10 @@ entt::entity scrapAtWorldPos(EntityAdmin& admin, QVector2D worldPos);
// Returns every scrap pile whose position falls within the inclusive tile rectangle
// spanned by tileA and tileB, in any corner order (REQ-UI-SCRAP-MULTI-SELECT).
std::vector<entt::entity> scrapInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);
// Returns every living actor (ship or defence station, player or enemy) that falls
// within the inclusive tile rectangle spanned by tileA and tileB, in any corner order
// (REQ-UI-MULTI-SELECT, REQ-UI-ENTITY-CLICK-SELECT). A ship is included when its floored
// position tile lies in the box; a station is included when any of its body cells does.
// Dead actors (hp <= 0) and the HQ proxy are excluded.
std::vector<entt::entity> actorsInBox(EntityAdmin& admin, QPoint tileA, QPoint tileB);

View File

@@ -40,7 +40,6 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
, m_nextBuildingId(1)
, m_buildingBlocksStock(m_config.world.startingBuildingBlocks)
, m_gameOver(false)
, m_hqBuildingId(kInvalidBuildingId)
, m_hqProxyEntity(entt::null)
, m_playerStation1Entity(entt::null)
, m_playerStation2Entity(entt::null)
@@ -137,7 +136,7 @@ void Simulation::reset(unsigned int seed)
m_gameOver = false;
m_isWon = false;
m_artifactCount = 0;
m_hqBuildingId = kInvalidBuildingId;
m_hqBuildingId = std::nullopt;
m_hqProxyEntity = entt::null;
m_playerStation1Entity = entt::null;
m_playerStation2Entity = entt::null;
@@ -217,11 +216,12 @@ void Simulation::apply(const Command& command)
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (id == kInvalidBuildingId)
const std::optional<BuildingId> placed = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (!placed.has_value())
{
break;
}
const BuildingId id = *placed;
if (c.recipeId.has_value())
{
m_buildingSystem->setRecipe(id, *c.recipeId);
@@ -237,31 +237,31 @@ void Simulation::apply(const Command& command)
break;
}
case CommandKind::Demolish:
demolish(static_cast<const DemolishCommand&>(command).id);
demolish(*static_cast<const DemolishCommand&>(command).id);
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
m_buildingSystem->rotateInPlace(*c.id, c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
m_buildingSystem->setRecipe(c.id, c.recipeId);
m_buildingSystem->setRecipe(*c.id, c.recipeId);
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
m_buildingSystem->setShipLayout(c.id, c.layout);
m_buildingSystem->setShipLayout(*c.id, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB);
m_buildingSystem->setSiteSplitterFilters(*c.id, c.filterA, c.filterB);
break;
}
case CommandKind::SetSplitterFilters:
@@ -1193,11 +1193,11 @@ bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
return it->second.unlocked;
}
BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
if (!m_buildingSystem->isPlacementValid(type, anchor, rotation))
{
return kInvalidBuildingId;
return std::nullopt;
}
int cost = 0;
@@ -1211,7 +1211,7 @@ BuildingId Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotati
}
if (m_buildingBlocksStock < cost)
{
return kInvalidBuildingId;
return std::nullopt;
}
m_buildingBlocksStock -= cost;
return m_buildingSystem->place(type, anchor, rotation, m_currentTick);

View File

@@ -2,6 +2,7 @@
#include <map>
#include <memory>
#include <optional>
#include <random>
#include <set>
#include <string>
@@ -131,8 +132,8 @@ private:
// Reached during play exclusively via apply(); never called by UI/app code.
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Returns the new entity id, or nullopt if blocks are insufficient.
std::optional<BuildingId> tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
@@ -182,7 +183,7 @@ private:
int m_artifactCount = 0;
// Pre-placed structure IDs.
BuildingId m_hqBuildingId; // Building id (for belt integration)
std::optional<BuildingId> m_hqBuildingId; // Building id (for belt integration)
entt::entity m_hqProxyEntity; // ECS entity (HP, targeting)
entt::entity m_playerStation1Entity;
entt::entity m_playerStation2Entity;

View File

@@ -954,7 +954,7 @@ TEST_CASE("BehaviorSystem: full-cargo salvage ship moves toward SalvageBay", "[b
Fixture f;
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0);
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{
@@ -989,7 +989,7 @@ TEST_CASE("SalvagerSystem: full-cargo ship at its SalvageBay hands over cargo",
Fixture f;
const BuildingId bayId = f.buildings.place(BuildingType::SalvageBay,
QPoint(-4, 0), Rotation::East, 0);
QPoint(-4, 0), Rotation::East, 0).value();
Tick t = 0;
for (int i = 0; i < 500; ++i)
{

View File

@@ -527,9 +527,9 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
const QPoint offsetB( 1, 0);
const BuildingId idA = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetA, Rotation::East);
BuildingType::Belt, cursor + offsetA, Rotation::East).value();
const BuildingId idB = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetB, Rotation::East);
BuildingType::Belt, cursor + offsetB, Rotation::East).value();
REQUIRE(idA != kInvalidBuildingId);
REQUIRE(idB != kInvalidBuildingId);
@@ -583,11 +583,11 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
}
const int blocksBeforeAttempt = sim.getBuildingBlocksStock();
const BuildingId id = SimulationTestAccess::place(sim,
const std::optional<BuildingId> id = SimulationTestAccess::place(sim,
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged.
REQUIRE(id == kInvalidBuildingId);
REQUIRE_FALSE(id.has_value());
REQUIRE(sim.getBuildingBlocksStock() == blocksBeforeAttempt);
}
@@ -599,10 +599,10 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
// rule, so it must be rejected without consuming building blocks.
const BuildingId id =
const std::optional<BuildingId> id =
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East);
REQUIRE(id == kInvalidBuildingId);
REQUIRE_FALSE(id.has_value());
REQUIRE(sim.getBuildingBlocksStock() == startBlocks);
REQUIRE(sim.getBuildings().getAllSites().empty());
}
@@ -623,7 +623,7 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
// Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at
// (-3,0),(-2,0),(-3,1); a valid spot.
const BuildingId id =
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildingBlocksStock() == startBlocks - minerCost);
@@ -666,7 +666,7 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
Simulation sim(loadConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
@@ -681,7 +681,7 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
{
Simulation sim(loadConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
@@ -710,7 +710,7 @@ TEST_CASE("Blueprint creation: a construction site is captured", "[blueprint]")
// Freshly placed → a ConstructionSite (not ticked to completion). A 1x1 belt keeps
// the body-cell bounding-box centered on the anchor, so a single site → zero offset.
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr);
@@ -727,7 +727,7 @@ TEST_CASE("Blueprint creation: a construction site's recipe is captured", "[blue
Simulation sim(loadConfig());
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
@@ -744,7 +744,7 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
// Building A: place, configure, and tick to completion so it is operational.
const BuildingId idA =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(idA != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idA, "mine_iron_ore");
for (int i = 0; i <= static_cast<int>(secondsToTicks(10.0)); ++i) { sim.tick(); }
@@ -752,7 +752,7 @@ TEST_CASE("Blueprint creation: mixed operational building and construction site
// Building B: place and configure, but leave as a construction site.
const BuildingId idB =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-6, 0), Rotation::East).value();
REQUIRE(idB != kInvalidBuildingId);
SimulationTestAccess::buildings(sim).setRecipe(idB, "mine_copper_ore");
REQUIRE(sim.getBuildings().findSite(idB) != nullptr);
@@ -777,7 +777,7 @@ TEST_CASE("Blueprint creation: selectionHasPlaceableBuilding sees a construction
REQUIRE_FALSE(selectionHasPlaceableBuilding(sim, {}));
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);
REQUIRE(selectionHasPlaceableBuilding(sim, { id }));
@@ -847,7 +847,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
// S-tile at (0,0) and (0,1) — x >= 0, valid space tiles.
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
@@ -871,7 +871,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
{
Simulation sim(loadConfig());
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;

View File

@@ -112,7 +112,7 @@ TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
// A placed miner enters the construction queue as a site (not yet operational).
const BuildingId id =
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.getBuildings().findBuilding(id) == nullptr);
REQUIRE(sim.getBuildings().findSite(id) != nullptr);

View File

@@ -119,7 +119,7 @@ TEST_CASE("BuildingSystem: place miner occupies expected body tiles", "[building
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
// Miner mask ["AA","A>"] with East rotation → body at (0,0),(1,0),(0,1).
@@ -138,8 +138,8 @@ TEST_CASE("BuildingSystem: place rejects a building above the world (y < 0)", "[
// Miner mask ["AA","A>"] East → body at (0,0),(1,0),(0,1); at y=-1 the top
// row sits above the world.
const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, -1), Rotation::East, 0);
REQUIRE(id == kInvalidBuildingId);
const std::optional<BuildingId> id = f.bs.place(BuildingType::Miner, QPoint(0, -1), Rotation::East, 0);
REQUIRE_FALSE(id.has_value());
REQUIRE(f.bs.getAllSites().empty());
REQUIRE_FALSE(f.bs.isTileOccupied(QPoint(0, 0)));
}
@@ -151,9 +151,9 @@ TEST_CASE("BuildingSystem: place rejects a building below the world (y >= height
// Anchored on the last in-bounds row, the miner's lower body row reaches
// y == heightTiles, which is outside the world.
const BuildingId id = f.bs.place(BuildingType::Miner,
const std::optional<BuildingId> id = f.bs.place(BuildingType::Miner,
QPoint(0, heightTiles - 1), Rotation::East, 0);
REQUIRE(id == kInvalidBuildingId);
REQUIRE_FALSE(id.has_value());
REQUIRE(f.bs.getAllSites().empty());
}
@@ -162,9 +162,9 @@ TEST_CASE("BuildingSystem: place rejects a building left of the asteroid edge",
PlacementFixture f;
const int leftEdgeX = -f.cfg.world.regions.asteroidWidth_tiles;
const BuildingId id = f.bs.place(BuildingType::Miner,
const std::optional<BuildingId> id = f.bs.place(BuildingType::Miner,
QPoint(leftEdgeX - 1, 0), Rotation::East, 0);
REQUIRE(id == kInvalidBuildingId);
REQUIRE_FALSE(id.has_value());
REQUIRE(f.bs.getAllSites().empty());
}
@@ -176,7 +176,7 @@ TEST_CASE("BuildingSystem: place accepts a building flush against the world's le
// Miner body min relative x is 0, so its leftmost cell sits exactly on the edge.
const BuildingId id = f.bs.place(BuildingType::Miner,
QPoint(leftEdgeX, 0), Rotation::East, 0);
QPoint(leftEdgeX, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(f.bs.isTileOccupied(QPoint(leftEdgeX, 0)));
}
@@ -187,7 +187,7 @@ TEST_CASE("BuildingSystem: place imposes no right-side bound (space extends righ
PlacementFixture f;
const BuildingId id = f.bs.place(BuildingType::Miner,
QPoint(1000, 0), Rotation::East, 0);
QPoint(1000, 0), Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
}
@@ -256,7 +256,7 @@ TEST_CASE("BuildingSystem: placed building enters construction queue", "[buildin
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(bs.getAllSites().size() == 1);
REQUIRE(bs.getAllBuildings().empty());
@@ -277,7 +277,7 @@ TEST_CASE("BuildingSystem: demolish frees tiles and returns refund", "[building]
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
@@ -351,7 +351,7 @@ TEST_CASE("BuildingSystem: construction completes after configured duration", "[
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
// Miner construction_time_seconds = 10. completesAt = secondsToTicks(10) = 300.
// We need to process tick 300 itself, so run 301 ticks (ticks 0..300).
@@ -377,7 +377,7 @@ TEST_CASE("BuildingSystem: second building starts after first completes", "[buil
rng);
bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0);
const BuildingId id2 = bs.place(BuildingType::Miner, QPoint(5, 5), Rotation::East, 0).value();
// Process through tick 300 to complete first miner's construction.
Tick tick = 0;
@@ -406,7 +406,7 @@ TEST_CASE("BuildingSystem: miner produces iron_ore after recipe duration", "[bui
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -439,7 +439,7 @@ TEST_CASE("BuildingSystem: miner output buffer stalls when full", "[building]")
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -479,8 +479,8 @@ TEST_CASE("BuildingSystem: productionBuildingCount excludes construction sites",
[](const std::string&) -> bool { return true; },
rng);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(10, 0), Rotation::East, 0).value();
(void)smelterId;
Tick tick = 0;
@@ -519,7 +519,7 @@ TEST_CASE("BuildingSystem: activeProductionBuildingCount tracks production cycle
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -563,7 +563,7 @@ TEST_CASE("BuildingSystem: smelter input buffer fills from adjacent west-flowing
// Smelter mask ["AA ","AA>"] → body (0,0),(1,0),(0,1),(1,1).
// Output port (2,1) East. Input port example: (2,0) West.
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
// Smelters have no recipe selection (REQ-BLD-SMELTER); they auto-accept any
// ore/scrap that is an input to a smelter recipe.
@@ -603,7 +603,7 @@ TEST_CASE("BuildingSystem: accepted input travels inward before entering the buf
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
@@ -645,7 +645,7 @@ TEST_CASE("BuildingSystem: input reservation caps buffered plus in-transit at th
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0);
QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(25.0)) + 1, tick);
@@ -686,7 +686,7 @@ TEST_CASE("BuildingSystem: smelter auto-smelts ore without a recipe selection",
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
@@ -732,7 +732,7 @@ TEST_CASE("BuildingSystem: smelter runs a satisfiable recipe while an incomplete
[](const std::string&) -> bool { return true; },
rng);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId sid = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(15.0)) + 1, tick);
@@ -786,7 +786,7 @@ TEST_CASE("BuildingSystem: miner output buffer drains onto adjacent belt", "[bui
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
// Belt at the miner's output port tile (1,1) flowing East.
@@ -826,12 +826,12 @@ TEST_CASE("BuildingSystem: output port couples directly into an adjacent input p
rng);
// Miner at (0,0): body (0,0),(1,0),(0,1); output port tile (1,1) flowing East.
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(minerId, "mine_iron_ore");
// Smelter anchored at (1,1): body (1,1),(2,1),(1,2),(2,2). Its body cell (1,1) is
// the miner's output-port tile, and its west input edge there faces East, so the
// two ports meet — no belt placed anywhere.
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0);
const BuildingId smelterId = bs.place(BuildingType::Smelter, QPoint(1, 1), Rotation::East, 0).value();
Tick tick = 0;
// Smelter build (15s) + margin for coupling and a smelt cycle.
@@ -866,11 +866,11 @@ TEST_CASE("BuildingSystem: direct coupling to a non-consumer leaves the item stu
rng);
// Producing miner at (0,0), output port (1,1) East.
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId minerId = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(minerId, "mine_iron_ore");
// A second, idle miner anchored at (1,1) occupies the output-port tile but takes
// no inputs, so it cannot accept the iron_ore.
const BuildingId sinkId = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0);
const BuildingId sinkId = bs.place(BuildingType::Miner, QPoint(1, 1), Rotation::East, 0).value();
Tick tick = 0;
// Both miners build sequentially (10s each), then the producer runs and jams.
@@ -904,7 +904,7 @@ TEST_CASE("BuildingSystem: setRecipe clears output buffer and active production"
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value();
bs.setRecipe(id, "mine_iron_ore");
Tick tick = 0;
@@ -948,7 +948,7 @@ TEST_CASE("BuildingSystem: reprocessing plant output buffer capacity equals max
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0);
QPoint(0, 0), Rotation::East, 0).value();
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
@@ -980,7 +980,7 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
rng);
const BuildingId id = bs.place(BuildingType::ReprocessingPlant,
QPoint(0, 0), Rotation::East, 0);
QPoint(0, 0), Rotation::East, 0).value();
// Reprocessing plants have no recipe selection (REQ-BLD-REPROCESSING); the
// single reprocessing recipe is applied automatically on completion.
@@ -1056,7 +1056,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a que
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North);
@@ -1079,7 +1079,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
@@ -1154,7 +1154,7 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
// same four body tiles, so findRotateInPlaceTarget must still return the id.
const BuildingId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0).value();
const std::optional<BuildingId> result =
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North);
@@ -1181,7 +1181,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a constru
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
REQUIRE(bs.findSite(id)->rotation == Rotation::East);
bs.rotateInPlace(id, Rotation::North);
@@ -1204,7 +1204,7 @@ TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
const Tick completesAt = bs.findSite(id)->completesAt;
REQUIRE(completesAt > 0);
@@ -1228,7 +1228,7 @@ TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direct
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
@@ -1259,7 +1259,7 @@ TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSyste
[](const std::string&) -> bool { return true; },
rng);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const BuildingId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0).value();
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
@@ -1276,7 +1276,7 @@ TEST_CASE("BuildingSystem: splitter filters configured on a construction site ca
PlacementFixture f;
const QPoint tile(5, 5);
const BuildingId id = f.bs.place(BuildingType::Splitter, tile, Rotation::East, 0);
const BuildingId id = f.bs.place(BuildingType::Splitter, tile, Rotation::East, 0).value();
REQUIRE(id != kInvalidBuildingId);
REQUIRE(f.bs.findSite(id) != nullptr);

View File

@@ -52,7 +52,7 @@ TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe"
viaCommand.apply(command);
const BuildingId id =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East).value();
SimulationTestAccess::buildings(viaDirect).setRecipe(id, "mine_iron_ore");
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
@@ -64,9 +64,9 @@ TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]")
Simulation viaDirect(loadConfig(), 99);
const BuildingId idA =
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
const BuildingId idB =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East).value();
REQUIRE(idA == idB);
DemolishCommand command;

View File

@@ -1,5 +1,6 @@
#include "catch.hpp"
#include <QSize>
#include <QVector2D>
#include <algorithm>
@@ -226,3 +227,45 @@ TEST_CASE("scrapInBox returns exactly the piles inside the tile rectangle", "[sc
REQUIRE(contains(hit, inB));
REQUIRE_FALSE(contains(hit, outX));
}
TEST_CASE("actorsInBox returns living ships and stations, excluding scrap and dead actors",
"[actor]")
{
EntityAdmin admin;
// Two living ships inside the box: one player, one enemy.
const entt::entity playerShip = admin.spawnShip(
QVector2D(1.5f, 2.5f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false); // tile (1,2)
const entt::entity enemyShip = admin.spawnShip(
QVector2D(4.2f, 5.8f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"raider", true); // tile (4,5)
// A dead ship inside the box is excluded.
const entt::entity deadShip = admin.spawnShip(
QVector2D(3.0f, 3.0f), 0.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false);
// A ship outside the box is excluded.
const entt::entity outsideShip = admin.spawnShip(
QVector2D(20.0f, 20.0f), 100.0f, 100.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 5.0f,
"fighter", false);
// A station is included when any body cell lies inside the box.
const std::vector<QPoint> stationCells{ QPoint(2, 2), QPoint(3, 2) };
const entt::entity station = admin.spawnStation(
QPoint(2, 2), QSize(2, 1), stationCells, 200.0f, 200.0f, true);
// Scrap and the HQ proxy are never actors.
admin.spawnScrap(QVector2D(1.0f, 1.0f), 5, Tick(1000));
admin.spawnHqProxy(QVector2D(0.5f, 0.5f), 500.0f, 500.0f);
const std::vector<entt::entity> hit = actorsInBox(admin, QPoint(5, 5), QPoint(0, 0));
REQUIRE(hit.size() == 3);
REQUIRE(contains(hit, playerShip));
REQUIRE(contains(hit, enemyShip));
REQUIRE(contains(hit, station));
REQUIRE_FALSE(contains(hit, deadShip));
REQUIRE_FALSE(contains(hit, outsideShip));
}

View File

@@ -224,7 +224,7 @@ TEST_CASE("ShipSystem: salvage_ship cargo capacity matches config", "[ship]")
// Cargo capacity is now a ship-level pool (REQ-MOD-CARGO-CAPACITY).
REQUIRE(admin.get<CargoComponent>(e).maxCapacity == 10);
REQUIRE(admin.get<CargoComponent>(e).current == 0);
REQUIRE(admin.get<DeliverScrapBehavior>(e).deliveryBay == kInvalidBuildingId);
REQUIRE_FALSE(admin.get<DeliverScrapBehavior>(e).deliveryBay.has_value());
REQUIRE_FALSE(admin.get<SalvageScrapBehavior>(e).scrapTarget.has_value());
REQUIRE(admin.get<SalvageScrapBehavior>(e).maxCollectionRange_tiles == Approx(50.0f));
}

View File

@@ -1,5 +1,7 @@
#pragma once
#include <optional>
#include <QPoint>
#include "BuildingId.h"
@@ -25,8 +27,8 @@ struct SimulationTestAccess
static BuildingSystem& buildings(Simulation& sim) { return sim.getBuildingsMutable(); }
static BeltSystem& belts(Simulation& sim) { return sim.getBeltsMutable(); }
static BuildingId place(Simulation& sim, BuildingType type, QPoint anchor,
Rotation rotation)
static std::optional<BuildingId> place(Simulation& sim, BuildingType type,
QPoint anchor, Rotation rotation)
{
return sim.tryPlaceBuilding(type, anchor, rotation);
}

View File

@@ -28,7 +28,6 @@ BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidge
, m_sim(sim)
, m_config(config)
, m_currentBlocks(0)
, m_activeIndex(-1)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
@@ -80,11 +79,11 @@ void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEven
void BlueprintPanel::clearActiveBlueprintButton()
{
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
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_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
}
m_activeIndex = -1;
m_activeIndex = std::nullopt;
refreshButtonStates();
}
@@ -109,13 +108,13 @@ void BlueprintPanel::onDeleteBlueprintClicked(int index)
{
if (m_activeIndex == index)
{
m_activeIndex = -1;
m_activeIndex = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
}
else if (m_activeIndex > index)
else if (m_activeIndex.has_value() && *m_activeIndex > index)
{
m_activeIndex--;
--*m_activeIndex;
}
m_blueprints.erase(m_blueprints.begin() + index);
rebuildButtons();
@@ -133,9 +132,9 @@ void BlueprintPanel::onBlueprintButtonClicked(int index)
return;
}
if (m_activeIndex >= 0 && m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
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_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
}
m_activeIndex = index;

View File

@@ -1,5 +1,6 @@
#pragma once
#include <optional>
#include <vector>
#include <QWidget>
@@ -56,7 +57,7 @@ private:
const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds;
int m_currentBlocks;
int m_activeIndex;
std::optional<int> m_activeIndex; // nullopt = no blueprint selected
std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons;
QPushButton* m_createBtn;

View File

@@ -37,7 +37,7 @@
#include "ReplayRecorder.h"
#include "DemolishModeChangedEvent.h"
#include "EntityHitTest.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "FacingComponent.h"
#include "FactionComponent.h"
@@ -142,7 +142,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
, m_ghostValid(false)
, m_dragging(false)
, m_demolishMode(false)
, m_demolishHoverBuildingId(kInvalidBuildingId)
, m_debugDraw(false)
, m_rng(std::random_device{}())
, m_boxSelecting(false)
@@ -285,6 +284,7 @@ void GameWorldView::onFrame()
// Drop selected scrap piles that were collected or despawned this frame, so the
// panel stops counting them and the selection empties out (REQ-UI-SCRAP-CLICK-SELECT).
pruneDespawnedScrap();
pruneDespawnedActors();
// Expire copy/paste flashes. Lifetime is wall-clock (the frame delta), so the
// flash plays for a fixed real duration regardless of game speed, including
@@ -621,7 +621,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
return true;
}
BuildingId GameWorldView::buildingAtTile(QPoint tile) const
std::optional<BuildingId> GameWorldView::buildingAtTile(QPoint tile) const
{
for (const Building& b : m_sim->getBuildings().getAllBuildings())
{
@@ -633,10 +633,10 @@ BuildingId GameWorldView::buildingAtTile(QPoint tile) const
}
}
}
return kInvalidBuildingId;
return std::nullopt;
}
BuildingId GameWorldView::siteAtTile(QPoint tile) const
std::optional<BuildingId> GameWorldView::siteAtTile(QPoint tile) const
{
for (const ConstructionSite& s : m_sim->getBuildings().getAllSites())
{
@@ -648,7 +648,7 @@ BuildingId GameWorldView::siteAtTile(QPoint tile) const
}
}
}
return kInvalidBuildingId;
return std::nullopt;
}
@@ -726,6 +726,43 @@ void GameWorldView::pruneDespawnedScrap()
}
}
void GameWorldView::clearEntitySelection()
{
if (m_selectedEntities.empty()) { return; }
m_selectedEntities.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
}
void GameWorldView::pruneDespawnedActors()
{
if (m_selectedEntities.empty()) { return; }
EntityAdmin& admin = m_sim->getAdmin();
std::vector<entt::entity> live;
for (entt::entity e : m_selectedEntities)
{
if (admin.isValid(e) && admin.hasAll<HealthComponent>(e)
&& admin.get<HealthComponent>(e).hp > 0.0f)
{
live.push_back(e);
}
}
if (live.size() != m_selectedEntities.size())
{
m_selectedEntities = std::move(live);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
}
}
bool GameWorldView::isEntitySelected(entt::entity entity) const
{
return std::find(m_selectedEntities.begin(), m_selectedEntities.end(), entity)
!= m_selectedEntities.end();
}
void GameWorldView::stepSpeed(int delta)
{
const double kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
@@ -1306,7 +1343,7 @@ void GameWorldView::drawStations(QPainter& painter)
painter.setBrush(Qt::NoBrush);
painter.drawRect(bboxRect);
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
if (isEntitySelected(e))
{
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
painter.setBrush(Qt::NoBrush);
@@ -1353,7 +1390,7 @@ void GameWorldView::drawShips(QPainter& painter)
painter.setBrush(it->second.fill);
painter.drawPolygon(tri);
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
if (isEntitySelected(e))
{
painter.setPen(QPen(m_visuals->overlays.selectedOutline, 2));
painter.setBrush(Qt::NoBrush);
@@ -1617,9 +1654,9 @@ void GameWorldView::drawOverlays(QPainter& painter)
}
}
}
else if (m_demolishMode && m_demolishHoverBuildingId != kInvalidBuildingId)
else if (m_demolishMode && m_demolishHoverBuildingId.has_value())
{
const Building* b = m_sim->getBuildings().findBuilding(m_demolishHoverBuildingId);
const Building* b = m_sim->getBuildings().findBuilding(*m_demolishHoverBuildingId);
if (b)
{
for (const QPoint& cell : b->bodyCells)
@@ -1989,9 +2026,9 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
// Shift + right-click copies a building's settings, but only in the
// default selection mode (REQ-BLD-COPY-CONFIG).
const QPoint tile = widgetToTile(event->pos());
BuildingId id = buildingAtTile(tile);
if (id == kInvalidBuildingId) { id = siteAtTile(tile); }
if (id != kInvalidBuildingId) { copyConfigFrom(id); }
std::optional<BuildingId> id = buildingAtTile(tile);
if (!id.has_value()) { id = siteAtTile(tile); }
if (id.has_value()) { copyConfigFrom(*id); }
}
}
return;
@@ -2032,48 +2069,31 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
// selection. Only active in the default selection mode.
if ((event->modifiers() & Qt::ShiftModifier) && m_copiedConfig.has_value())
{
BuildingId id = buildingAtTile(tile);
if (id == kInvalidBuildingId) { id = siteAtTile(tile); }
if (id != kInvalidBuildingId)
std::optional<BuildingId> id = buildingAtTile(tile);
if (!id.has_value()) { id = siteAtTile(tile); }
if (id.has_value())
{
pasteConfigTo(id);
pasteConfigTo(*id);
return;
}
}
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
const QVector2D worldPos = widgetToWorld(event->pos());
const entt::entity hitEntity = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (hitEntity != entt::null)
{
// Actors (ship/station) win over scrap and buildings (REQ-UI-SCRAP-CLICK-SELECT).
clearScrapSelection();
m_selectedBuildingIds.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
m_selectedEntity = hitEntity;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectedEvent>(hitEntity));
}
else
{
if (m_selectedEntity.has_value())
{
m_selectedEntity = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectedEvent>(std::nullopt));
}
// Point hit-test precedence: buildings win over actors, which win over scrap
// (REQ-UI-SELECTION-CATEGORIES).
std::optional<BuildingId> buildingHit = buildingAtTile(tile);
if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
BuildingId id = buildingAtTile(tile);
if (id == kInvalidBuildingId)
if (buildingHit.has_value())
{
id = siteAtTile(tile);
}
if (id != kInvalidBuildingId)
{
// A building/construction site outranks scrap (REQ-UI-SCRAP-CLICK-SELECT).
const BuildingId id = *buildingHit;
// A building selection is exclusive: it clears any field selection —
// actors and scrap — because buildings win (REQ-UI-SELECTION-CATEGORIES).
clearEntitySelection();
clearScrapSelection();
if (event->modifiers() & Qt::ControlModifier)
if (ctrl)
{
bool found = false;
std::vector<BuildingId> newSel;
@@ -2091,20 +2111,58 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
return;
}
else if (const entt::entity scrapHit =
scrapAtWorldPos(m_sim->getAdmin(), worldPos); scrapHit != entt::null)
// Selecting a field object (actor or scrap) clears any building selection but
// lets actors and scrap coexist (REQ-UI-SELECTION-CATEGORIES).
const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (actorHit != entt::null)
{
// Scrap forms its own selection category; picking it clears any
// building selection (REQ-UI-SCRAP-CLICK-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
if (!m_selectedBuildingIds.empty())
{
m_selectedBuildingIds.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
}
if (event->modifiers() & Qt::ControlModifier)
if (ctrl)
{
// Toggle this actor within the field selection, leaving scrap intact
// (REQ-UI-ENTITY-CLICK-SELECT).
bool found = false;
std::vector<entt::entity> newSel;
for (entt::entity sel : m_selectedEntities)
{
if (sel == actorHit) { found = true; }
else { newSel.push_back(sel); }
}
if (!found) { newSel.push_back(actorHit); }
m_selectedEntities = newSel;
}
else
{
// A plain click makes this actor the sole selection.
m_selectedEntities = { actorHit };
clearScrapSelection();
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
return;
}
if (const entt::entity scrapHit =
scrapAtWorldPos(m_sim->getAdmin(), worldPos); scrapHit != entt::null)
{
if (!m_selectedBuildingIds.empty())
{
m_selectedBuildingIds.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
}
if (ctrl)
{
// Toggle this pile within the field selection, leaving actors intact
// (REQ-UI-SCRAP-MULTI-SELECT).
bool found = false;
std::vector<entt::entity> newSel;
for (entt::entity sel : m_selectedScrap)
@@ -2117,18 +2175,26 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
}
else
{
// A plain click makes this pile the sole selection.
m_selectedScrap = { scrapHit };
clearEntitySelection();
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
return;
}
else
// Empty space: a plain click clears the whole selection and starts a box drag;
// Ctrl preserves the current selection for additive box-select.
if (!ctrl)
{
if (!(event->modifiers() & Qt::ControlModifier))
if (!m_selectedBuildingIds.empty())
{
m_selectedBuildingIds.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
}
clearEntitySelection();
clearScrapSelection();
}
m_boxSelecting = true;
@@ -2136,8 +2202,6 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
m_boxCurrentTile = tile;
}
}
}
}
void GameWorldView::mouseMoveEvent(QMouseEvent* event)
{
@@ -2198,7 +2262,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
command->id = id;
enqueueCommand(command);
}
m_demolishHoverBuildingId = kInvalidBuildingId;
m_demolishHoverBuildingId = std::nullopt;
return;
}
@@ -2206,8 +2270,9 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
if (!boxIds.empty())
{
// A box covering any building selects buildings; scrap in the box is
// ignored (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
// A box covering any building selects buildings; field objects (actors and
// scrap) in the box are ignored — buildings win (REQ-UI-MULTI-SELECT).
clearEntitySelection();
clearScrapSelection();
if (!ctrl)
{
@@ -2230,11 +2295,13 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
return;
}
// No buildings in the box: a scrap-only box selects the scrap it covers
// (REQ-UI-SCRAP-MULTI-SELECT).
// No buildings in the box: select the field objects it covers — ships, defence
// stations, and scrap together (REQ-UI-MULTI-SELECT, REQ-UI-SCRAP-MULTI-SELECT).
const std::vector<entt::entity> boxActors =
actorsInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
const std::vector<entt::entity> boxScrap =
scrapInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
if (!boxScrap.empty())
if (!boxActors.empty() || !boxScrap.empty())
{
if (!m_selectedBuildingIds.empty())
{
@@ -2244,10 +2311,20 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
}
if (!ctrl)
{
m_selectedEntities = boxActors;
m_selectedScrap = boxScrap;
}
else
{
for (entt::entity e : boxActors)
{
bool found = false;
for (entt::entity sel : m_selectedEntities)
{
if (sel == e) { found = true; break; }
}
if (!found) { m_selectedEntities.push_back(e); }
}
for (entt::entity e : boxScrap)
{
bool found = false;
@@ -2258,6 +2335,8 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
if (!found) { m_selectedScrap.push_back(e); }
}
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EntitySelectionChangedEvent>(m_selectedEntities));
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ScrapSelectionChangedEvent>(m_selectedScrap));
return;
@@ -2269,6 +2348,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
m_selectedBuildingIds.clear();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionChangedEvent>(m_selectedBuildingIds));
clearEntitySelection();
clearScrapSelection();
}
}
@@ -2283,7 +2363,7 @@ void GameWorldView::toggleDemolishMode()
if (m_demolishMode)
{
m_demolishMode = false;
m_demolishHoverBuildingId = kInvalidBuildingId;
m_demolishHoverBuildingId = std::nullopt;
}
else
{
@@ -2479,10 +2559,12 @@ void GameWorldView::resetForNewGame()
m_ghostRotation = Rotation::East;
m_ghostValid = false;
m_demolishMode = false;
m_demolishHoverBuildingId = kInvalidBuildingId;
m_demolishHoverBuildingId = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DemolishModeChangedEvent>(false));
m_selectedBuildingIds.clear();
clearEntitySelection();
clearScrapSelection();
m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear();
m_boxSelecting = false;

View File

@@ -36,7 +36,7 @@
#include "entt/entity/entity.hpp"
#include "CommandManager.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "GameConfig.h"
#include "Rotation.h"
#include "Tick.h"
@@ -168,8 +168,8 @@ private:
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
const BuildingDef* findBuildingDef(BuildingType type) const;
BuildingId buildingAtTile(QPoint tile) const;
BuildingId siteAtTile(QPoint tile) const;
std::optional<BuildingId> buildingAtTile(QPoint tile) const;
std::optional<BuildingId> siteAtTile(QPoint tile) const;
// Ids of all buildings and construction sites whose footprint intersects
// the tile box spanned by the two (unordered) corner tiles.
std::vector<BuildingId> buildingsInBox(QPoint cornerA, QPoint cornerB) const;
@@ -193,6 +193,14 @@ private:
// Drops despawned or fully-collected piles from the scrap selection and re-emits
// when it changed (REQ-UI-SCRAP-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedScrap();
// Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was
// non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over.
void clearEntitySelection();
// Drops despawned or dead actors from the selection and re-emits when it changed
// (REQ-UI-ENTITY-CLICK-SELECT). Called each frame from onFrame().
void pruneDespawnedActors();
// True if the given actor is part of the current actor selection.
bool isEntitySelected(entt::entity entity) const;
void stepSpeed(int delta);
void placeAtTile(QPoint tile);
@@ -270,11 +278,11 @@ private:
static constexpr qint64 kCopyFlashDurationMs = 300;
bool m_demolishMode;
BuildingId m_demolishHoverBuildingId;
std::optional<BuildingId> m_demolishHoverBuildingId;
bool m_debugDraw;
std::vector<BuildingId> m_selectedBuildingIds;
std::optional<entt::entity> m_selectedEntity;
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedScrap;
bool m_boxSelecting;
QPoint m_boxStartTile;

View File

@@ -9,14 +9,16 @@
#include <QLabel>
#include <QListWidget>
#include <QPushButton>
#include <QStringList>
#include <QVBoxLayout>
#include "BeltSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "DisplayName.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
@@ -131,7 +133,6 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_singleBuildingId(kInvalidBuildingId)
, m_splitterTile(0, 0)
{
m_layout = new QVBoxLayout(this);
@@ -170,10 +171,10 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
connect(m_clearBeltBtn, &QPushButton::clicked,
this, &SelectedBuildingPanel::onClearBelt);
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
if (m_singleBuildingId != kInvalidBuildingId)
if (m_singleBuildingId.has_value())
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<LayoutDialogRequestedEvent>(m_singleBuildingId));
std::make_shared<LayoutDialogRequestedEvent>(*m_singleBuildingId));
}
});
connect(m_filterAList, &QListWidget::itemChanged,
@@ -197,6 +198,11 @@ SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
m_layout->addWidget(m_stationStatsLabel);
m_stationStatsLabel->hide();
m_entitySummaryLabel = new QLabel(this);
m_entitySummaryLabel->setWordWrap(true);
m_layout->addWidget(m_entitySummaryLabel);
m_entitySummaryLabel->hide();
m_scrapLabel = new QLabel(this);
m_layout->addWidget(m_scrapLabel);
m_scrapLabel->hide();
@@ -216,8 +222,9 @@ void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& id
m_selectedBuildingIds = ids;
if (!ids.empty())
{
// A building selection is exclusive: it supersedes any field selection —
// actors and scrap (REQ-UI-SELECTION-CATEGORIES).
clearEntityDisplay();
// A building selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT).
m_selectedScrap.clear();
m_scrapLabel->hide();
}
@@ -257,7 +264,7 @@ void SelectedBuildingPanel::hideAllWidgets()
void SelectedBuildingPanel::clearContent()
{
m_singleBuildingId = kInvalidBuildingId;
m_singleBuildingId = std::nullopt;
hideAllWidgets();
}
@@ -267,6 +274,7 @@ void SelectedBuildingPanel::buildEmpty()
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
}
void SelectedBuildingPanel::buildSingle(BuildingId id)
@@ -659,21 +667,29 @@ void SelectedBuildingPanel::handleEvent(
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
{
if (!m_selectedScrap.empty())
if (!m_selectedEntities.empty() || !m_selectedScrap.empty())
{
// The total shrinks live as piles are collected or despawn (REQ-UI-SCRAP-PANEL).
refreshScrapTotal();
return;
}
if (m_selectedEntity.has_value())
// Field selection. Keep the live values current: the single-actor stats panel,
// the standalone scrap total, or the count summary (whose scrap line shrinks as
// piles are collected) — matching the layout chosen by buildFieldSelection()
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-SCRAP-PANEL).
if (m_selectedEntities.size() == 1 && m_selectedScrap.empty())
{
refreshEntityStats();
}
else if (m_selectedEntities.empty())
{
refreshScrapTotal();
}
else
{
buildEntitySummary();
}
return;
}
if (m_singleBuildingId == kInvalidBuildingId) { return; }
const Building* b = m_sim->getBuildings().findBuilding(m_singleBuildingId);
if (!m_singleBuildingId.has_value()) { return; }
const Building* b = m_sim->getBuildings().findBuilding(*m_singleBuildingId);
if (b)
{
if (m_titleLabel->text().startsWith(tr("(Building) ")))
@@ -686,7 +702,7 @@ void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
}
return;
}
const ConstructionSite* s = m_sim->getBuildings().findSite(m_singleBuildingId);
const ConstructionSite* s = m_sim->getBuildings().findSite(*m_singleBuildingId);
if (s)
{
// A periodic tick only advances construction progress, so update just the
@@ -708,7 +724,7 @@ void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
{
m_singleBuildingId = kInvalidBuildingId;
m_singleBuildingId = std::nullopt;
m_recipeSelectButton->hide();
m_clearBeltBtn->hide();
m_filterALabel->hide();
@@ -738,7 +754,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
QString text;
for (const std::pair<const BuildingType, int>& entry : counts)
{
text += buildingTypeName(entry.first) + ": "
text += buildingTypeName(entry.first) + " x "
+ QString::number(entry.second) + "\n";
if (isBeltLike(entry.first))
{
@@ -764,7 +780,7 @@ void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
void SelectedBuildingPanel::onSelectRecipeClicked()
{
if (m_singleBuildingId == kInvalidBuildingId)
if (!m_singleBuildingId.has_value())
{
return;
}
@@ -775,7 +791,7 @@ void SelectedBuildingPanel::onSelectRecipeClicked()
// refreshBuffers() path picks up the new schematic (and shows the layout
// preview + Configure Layout button) once the command has been applied.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<RecipeSelectionRequestedEvent>(m_singleBuildingId));
std::make_shared<RecipeSelectionRequestedEvent>(*m_singleBuildingId));
rebuild();
}
@@ -825,7 +841,7 @@ void SelectedBuildingPanel::buildSplitterFilters(
void SelectedBuildingPanel::onSplitterFilterChanged()
{
if (m_singleBuildingId == kInvalidBuildingId)
if (!m_singleBuildingId.has_value())
{
return;
}
@@ -848,7 +864,7 @@ void SelectedBuildingPanel::onSplitterFilterChanged()
{
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = m_singleBuildingId;
command->id = *m_singleBuildingId;
command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
@@ -907,39 +923,139 @@ void SelectedBuildingPanel::onClearBelt()
}
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectedEvent> event)
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
{
if (event->entity.has_value())
m_selectedEntities = event->entities;
if (!m_selectedEntities.empty())
{
m_selectedEntity = event->entity;
// A field selection supersedes any building selection (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear();
// An entity selection supersedes any scrap selection (REQ-UI-SCRAP-CLICK-SELECT).
m_selectedScrap.clear();
m_scrapLabel->hide();
clearContent();
}
buildFieldSelection();
}
EntityAdmin& admin = m_sim->getAdmin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))
void SelectedBuildingPanel::buildFieldSelection()
{
if (m_selectedEntities.empty() && m_selectedScrap.empty())
{
// Nothing in the field category. Fall back to empty unless buildings own the panel.
clearEntityDisplay();
m_scrapLabel->hide();
if (m_selectedBuildingIds.empty())
{
buildEmpty();
}
return;
}
if (admin.hasAll<ShipIdentityComponent>(entity))
// A field selection owns the panel: drop any building content.
clearContent();
EntityAdmin& admin = m_sim->getAdmin();
// Full single-actor stats are shown only for a lone actor with no scrap. As soon as
// the selection holds more than one object (multiple actors, or an actor plus scrap),
// the panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
if (m_selectedEntities.size() == 1 && m_selectedScrap.empty())
{
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
const entt::entity entity = m_selectedEntities.front();
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
{
buildEntityShip(entity);
}
else if (admin.hasAll<StationBodyComponent>(entity))
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
else
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
}
return;
}
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
if (m_selectedEntities.empty())
{
// Scrap only: a single "Scrap: N" line.
m_entitySummaryLabel->hide();
refreshScrapTotal();
m_scrapLabel->show();
return;
}
// Actor counts, with the scrap total appended into the same label so every line
// shares the same spacing.
m_scrapLabel->hide();
buildEntitySummary();
}
void SelectedBuildingPanel::buildEntitySummary()
{
EntityAdmin& admin = m_sim->getAdmin();
// Group actors by faction + kind + ship schematic, preserving first-seen order
// (REQ-UI-FIELD-MULTI-SELECTION).
std::vector<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity entity : m_selectedEntities)
{
if (!admin.isValid(entity)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
const QString name = QString::fromStdString(toDisplayName(id));
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
+ QString::fromStdString(id);
label = isEnemy ? tr("Enemy %1").arg(name) : name;
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
}
else
{
clearEntityDisplay();
continue;
}
if (counts.find(key) == counts.end())
{
keys.push_back(key);
labels[key] = label;
}
counts[key] += 1;
}
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
// multi-selection). No total-count header, consistent with the building panel. The
// scrap total, when present, is appended as another line in the same label so the
// line spacing is uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-SCRAP-PANEL).
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_selectedScrap.empty())
{
lines << scrapTotalText();
}
m_entitySummaryLabel->setText(lines.join('\n'));
m_entitySummaryLabel->show();
}
void SelectedBuildingPanel::buildEntityShip(entt::entity entity)
@@ -1017,23 +1133,17 @@ void SelectedBuildingPanel::buildEntityStation(entt::entity entity)
void SelectedBuildingPanel::refreshEntityStats()
{
if (!m_selectedEntity.has_value()) { return; }
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
if (m_selectedEntities.size() != 1) { return; }
EntityAdmin& admin = m_sim->getAdmin();
entt::entity entity = *m_selectedEntity;
if (!admin.isValid(entity))
{
clearEntityDisplay();
return;
}
const entt::entity entity = m_selectedEntities.front();
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
const HealthComponent& health = admin.get<HealthComponent>(entity);
if (health.hp <= 0.0f)
{
clearEntityDisplay();
return;
}
if (health.hp <= 0.0f) { return; }
if (admin.hasAll<ShipIdentityComponent>(entity))
{
@@ -1050,10 +1160,11 @@ void SelectedBuildingPanel::refreshEntityStats()
void SelectedBuildingPanel::clearEntityDisplay()
{
m_selectedEntity = std::nullopt;
m_selectedEntities.clear();
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
@@ -1067,34 +1178,14 @@ void SelectedBuildingPanel::handleEvent(
m_selectedScrap = event->scrap;
if (!m_selectedScrap.empty())
{
// Scrap is its own selection category, mutually exclusive with buildings and
// entities (REQ-UI-SCRAP-CLICK-SELECT).
// Scrap is a field object: it supersedes any building selection but coexists
// with actors (REQ-UI-SELECTION-CATEGORIES).
m_selectedBuildingIds.clear();
clearContent();
clearEntityDisplay();
buildScrap();
}
else
{
m_scrapLabel->hide();
if (m_selectedBuildingIds.empty() && !m_selectedEntity.has_value())
{
buildEmpty();
}
}
buildFieldSelection();
}
void SelectedBuildingPanel::buildScrap()
{
clearContent();
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
refreshScrapTotal();
m_scrapLabel->show();
}
void SelectedBuildingPanel::refreshScrapTotal()
QString SelectedBuildingPanel::scrapTotalText() const
{
// Sum the remaining amounts of the still-living selected piles (REQ-UI-SCRAP-PANEL).
int total = 0;
@@ -1106,7 +1197,12 @@ void SelectedBuildingPanel::refreshScrapTotal()
total += info.amount;
}
}
m_scrapLabel->setText(tr("Scrap: %1").arg(total));
return tr("Scrap x %1").arg(total);
}
void SelectedBuildingPanel::refreshScrapTotal()
{
m_scrapLabel->setText(scrapTotalText());
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)

View File

@@ -13,7 +13,7 @@
#include "Building.h"
#include "BuildingId.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "PlayerCommandsAppliedEvent.h"
@@ -36,7 +36,7 @@ class QVBoxLayout;
class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectedEvent,
EntitySelectionChangedEvent,
SelectionChangedEvent,
ScrapSelectionChangedEvent,
DebugDrawToggledEvent>
@@ -51,7 +51,7 @@ public:
private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const ScrapSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
@@ -80,8 +80,9 @@ private:
void buildEmpty();
void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids);
void buildScrap();
void refreshScrapTotal();
// "Scrap: N" for the summed remaining amount of the selected piles (REQ-UI-SCRAP-PANEL).
QString scrapTotalText() const;
void refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type,
@@ -109,22 +110,30 @@ private:
ShipLayoutPreview* m_layoutPreview;
QPushButton* m_configureLayoutBtn;
BuildingId m_singleBuildingId;
std::optional<BuildingId> m_singleBuildingId;
bool m_singleIsSite = false; // selected single entity is a construction site
QPoint m_splitterTile;
std::string m_currentRecipeId;
bool m_debugDraw = false;
std::optional<entt::entity> m_selectedEntity;
// The selected ships/defence stations. Shares the "field" selection category with
// scrap (m_selectedScrap): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
std::vector<entt::entity> m_selectedEntities;
ShipStatsPanel* m_entityStatsPanel;
QLabel* m_entityTitleLabel;
QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
std::vector<entt::entity> m_selectedScrap;
QLabel* m_scrapLabel;
// Renders the combined field selection (actors + scrap): a single-actor stats panel
// or a multi-actor summary, plus the scrap total when scrap is also selected
// (REQ-UI-FIELD-MULTI-SELECTION).
void buildFieldSelection();
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void refreshEntityStats();
void clearEntityDisplay();
};

View File

@@ -83,7 +83,7 @@ public:
setFixedSize(cols * kCellSize + 1, rows * kCellSize + 1);
}
void setGhostData(int moduleIndex, Rotation rotation)
void setGhostData(std::optional<int> moduleIndex, Rotation rotation)
{
m_ghostModuleIdx = moduleIndex;
m_ghostRotation = rotation;
@@ -111,9 +111,9 @@ protected:
{
painter.fillRect(cellRect, QColor(30, 30, 30));
}
else if (cell.moduleIndex >= 0)
else if (cell.moduleIndex.has_value())
{
const PlacedModule& pm = (*m_placed)[cell.moduleIndex];
const PlacedModule& pm = (*m_placed)[*cell.moduleIndex];
const ModuleDef* def = findModule(pm.moduleId);
QColor color(Qt::gray);
QString glyph;
@@ -140,9 +140,9 @@ protected:
}
// Draw ghost
if (m_ghostModuleIdx >= 0 && m_hoverCell.x() >= 0 && m_config)
if (m_ghostModuleIdx.has_value() && m_hoverCell.x() >= 0 && m_config)
{
const ModuleDef& def = m_config->modules.modules[m_ghostModuleIdx];
const ModuleDef& def = m_config->modules.modules[*m_ghostModuleIdx];
const std::vector<std::string> mask = rotateMask(def.surfaceMask, m_ghostRotation);
QColor ghostColor(QString::fromStdString(def.fillColor));
ghostColor.setAlpha(100);
@@ -238,7 +238,7 @@ private:
int m_cols = 0;
const std::vector<PlacedModule>* m_placed = nullptr;
const GameConfig* m_config = nullptr;
int m_ghostModuleIdx = -2;
std::optional<int> m_ghostModuleIdx;
Rotation m_ghostRotation = Rotation::East;
QPoint m_hoverCell = QPoint(-1, -1);
};
@@ -374,7 +374,6 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
, m_rows(0)
, m_cols(0)
, m_placedModules(currentLayout.placedModules)
, m_activeModuleIndex(-2)
, m_currentRotation(Rotation::East)
, m_removeButton(nullptr)
, m_gridWidget(nullptr)
@@ -406,7 +405,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
}
// Initialize grid.
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, -1}));
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, std::nullopt}));
for (int r = 0; r < m_rows; ++r)
{
for (int c = 0; c < static_cast<int>(m_shipLayout[r].size()); ++c)
@@ -491,9 +490,9 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
}
buttonGrid->addWidget(m_removeButton, row, 0, 1, kCols);
connect(m_removeButton, &QPushButton::clicked, this, [this]() {
if (m_activeModuleIndex == -1)
if (m_removeMode)
{
m_activeModuleIndex = -2;
m_removeMode = false;
m_removeButton->setChecked(false);
}
else
@@ -502,7 +501,8 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
{
if (btn) { btn->setChecked(false); }
}
m_activeModuleIndex = -1;
m_activeModuleIndex = std::nullopt;
m_removeMode = true;
m_removeButton->setChecked(true);
}
updateGridWidget();
@@ -540,20 +540,20 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
// Grid click handler.
connect(this, &ShipLayoutDialog::gridCellClicked, this, [this](QPoint cell) {
if (m_activeModuleIndex == -2)
if (!m_removeMode && !m_activeModuleIndex.has_value())
{
return;
}
if (m_activeModuleIndex == -1)
if (m_removeMode)
{
// Remove mode: find and remove module at cell.
if (cell.y() >= 0 && cell.y() < m_rows && cell.x() >= 0 && cell.x() < m_cols)
{
const int idx = m_grid[cell.y()][cell.x()].moduleIndex;
if (idx >= 0)
const std::optional<int> idx = m_grid[cell.y()][cell.x()].moduleIndex;
if (idx.has_value())
{
m_placedModules.erase(m_placedModules.begin() + idx);
m_placedModules.erase(m_placedModules.begin() + *idx);
rebuildOccupancy();
updateGridWidget();
updateStats();
@@ -563,7 +563,7 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config,
}
// Place module.
const ModuleDef& def = m_config->modules.modules[m_activeModuleIndex];
const ModuleDef& def = m_config->modules.modules[*m_activeModuleIndex];
if (canPlaceModule(def, cell, m_currentRotation))
{
PlacedModule pm;
@@ -622,7 +622,7 @@ void ShipLayoutDialog::onModuleButtonClicked(int index)
if (m_activeModuleIndex == index)
{
if (m_moduleButtons[index]) { m_moduleButtons[index]->setChecked(false); }
m_activeModuleIndex = -2;
m_activeModuleIndex = std::nullopt;
}
else
{
@@ -631,6 +631,7 @@ void ShipLayoutDialog::onModuleButtonClicked(int index)
if (m_moduleButtons[i]) { m_moduleButtons[i]->setChecked(i == index); }
}
m_removeButton->setChecked(false);
m_removeMode = false;
m_activeModuleIndex = index;
}
updateGridWidget();
@@ -656,7 +657,7 @@ void ShipLayoutDialog::rebuildOccupancy()
{
for (int c = 0; c < m_cols; ++c)
{
m_grid[r][c].moduleIndex = -1;
m_grid[r][c].moduleIndex = std::nullopt;
}
}
@@ -726,7 +727,7 @@ bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position,
{
return false;
}
if (m_grid[gr][gc].moduleIndex >= 0)
if (m_grid[gr][gc].moduleIndex.has_value())
{
return false;
}

View File

@@ -47,7 +47,7 @@ public:
struct CellInfo
{
bool buildable;
int moduleIndex; // -1 if empty
std::optional<int> moduleIndex; // nullopt if empty
};
private:
@@ -69,7 +69,12 @@ private:
std::vector<PlacedModule> m_placedModules;
std::vector<std::vector<CellInfo>> m_grid;
int m_activeModuleIndex; // -1 = remove mode, -2 = no selection
// The module to place, as an index into config modules; nullopt when no
// module is selected for placement. m_removeMode is a separate mode in which
// clicking a cell removes the module there (mutually exclusive with a
// selected module).
std::optional<int> m_activeModuleIndex;
bool m_removeMode = false;
Rotation m_currentRotation;
std::vector<QPushButton*> m_moduleButtons;

View File

@@ -129,7 +129,7 @@ void ShipLayoutPreview::setShipAndLayout(const std::vector<std::string>& shipLay
}
}
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, -1}));
m_grid.assign(m_rows, std::vector<CellInfo>(m_cols, {false, std::nullopt}));
for (int r = 0; r < m_rows; ++r)
{
for (int c = 0; c < static_cast<int>(shipLayout[r].size()); ++c)
@@ -205,9 +205,9 @@ void ShipLayoutPreview::paintEvent(QPaintEvent* /*event*/)
{
painter.fillRect(cellRect, Qt::black);
}
else if (cell.moduleIndex >= 0)
else if (cell.moduleIndex.has_value())
{
const PlacedModule& pm = m_placedModules[cell.moduleIndex];
const PlacedModule& pm = m_placedModules[*cell.moduleIndex];
const ModuleDef* def = findModuleDef(*m_modules, pm.moduleId);
QColor color(Qt::gray);
if (def)

View File

@@ -1,5 +1,6 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
@@ -31,7 +32,7 @@ private:
struct CellInfo
{
bool buildable;
int moduleIndex; // -1 if empty
std::optional<int> moduleIndex; // nullopt if empty
};
std::vector<std::vector<CellInfo>> m_grid;