diff --git a/src/lib/core/BuildModeController.cpp b/src/lib/core/BuildModeController.cpp new file mode 100644 index 0000000..56d9be1 --- /dev/null +++ b/src/lib/core/BuildModeController.cpp @@ -0,0 +1,261 @@ +#include "BuildModeController.h" + +#include +#include + +#include "BlueprintModeExitedEvent.h" +#include "BuilderModeExitedEvent.h" +#include "DeconstructModeChangedEvent.h" +#include "EventManager.h" + +namespace +{ + +Rotation rotateClockwise(Rotation rotation) +{ + switch (rotation) + { + case Rotation::North: return Rotation::East; + case Rotation::East: return Rotation::South; + case Rotation::South: return Rotation::West; + case Rotation::West: return Rotation::North; + } + return Rotation::East; +} + +Rotation rotateCounterClockwise(Rotation rotation) +{ + switch (rotation) + { + case Rotation::North: return Rotation::West; + case Rotation::East: return Rotation::North; + case Rotation::South: return Rotation::East; + case Rotation::West: return Rotation::South; + } + return Rotation::East; +} + +} // namespace + +BuildMode BuildModeController::getMode() const +{ + return m_mode; +} + +bool BuildModeController::isBuilderMode() const +{ + return m_mode == BuildMode::Builder; +} + +bool BuildModeController::isBlueprintMode() const +{ + return m_mode == BuildMode::Blueprint; +} + +bool BuildModeController::isDeconstructMode() const +{ + return m_mode == BuildMode::Deconstruct; +} + +void BuildModeController::enterMode(BuildMode mode) +{ + if (m_mode == mode) { return; } + + // Leave the current mode properly, so its widget hears about it however the + // player left. Each exit clears only its own state. + switch (m_mode) + { + case BuildMode::Builder: + m_draggingBelt = false; + m_beltDragPath.clear(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + break; + case BuildMode::Blueprint: + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + break; + case BuildMode::Deconstruct: + m_deconstructHoverBuildingId.reset(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(false)); + break; + case BuildMode::None: + break; + } + + m_mode = mode; + + if (mode == BuildMode::Deconstruct) + { + EventManager::getInstance()->sendEventImmediately( + std::make_shared(true)); + } +} + +void BuildModeController::enterBuilderMode(BuildingType type) +{ + enterMode(BuildMode::Builder); + m_builderType = type; + m_ghostRotation = Rotation::East; + m_ghostValid = false; + m_tunnelGhostType = BuildingType::TunnelEntry; + m_tunnelPartnerTile.reset(); +} + +void BuildModeController::enterBlueprintMode(Blueprint blueprint) +{ + enterMode(BuildMode::Blueprint); + // The layout starts where the builder ghost last was, so switching from a + // building to a blueprint does not jump the preview across the world. + m_blueprintGhostTile = m_ghostTile; + m_blueprint = std::move(blueprint); +} + +void BuildModeController::toggleDeconstructMode() +{ + enterMode(isDeconstructMode() ? BuildMode::None : BuildMode::Deconstruct); +} + +void BuildModeController::exitBuilderMode() +{ + if (!isBuilderMode()) { return; } + enterMode(BuildMode::None); +} + +void BuildModeController::exitBlueprintMode() +{ + if (!isBlueprintMode()) { return; } + enterMode(BuildMode::None); +} + +void BuildModeController::exitCurrentMode() +{ + enterMode(BuildMode::None); +} + +BuildingType BuildModeController::getBuilderType() const +{ + return m_builderType; +} + +bool BuildModeController::isTunnelMode() const +{ + return isBuilderMode() && m_builderType == BuildingType::TunnelEntry; +} + +BuildingType BuildModeController::getEffectiveBuilderType() const +{ + return isTunnelMode() ? m_tunnelGhostType : m_builderType; +} + +QPoint BuildModeController::getGhostTile() const +{ + return m_ghostTile; +} + +Rotation BuildModeController::getGhostRotation() const +{ + return m_ghostRotation; +} + +bool BuildModeController::isGhostValid() const +{ + return m_ghostValid; +} + +void BuildModeController::setGhostTile(QPoint tile) +{ + m_ghostTile = tile; +} + +void BuildModeController::setGhostValidity(bool valid) +{ + m_ghostValid = valid; +} + +void BuildModeController::rotateGhost(bool clockwise) +{ + m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation) + : rotateCounterClockwise(m_ghostRotation); +} + +BuildingType BuildModeController::getTunnelGhostType() const +{ + return m_tunnelGhostType; +} + +const std::optional& BuildModeController::getTunnelPartnerTile() const +{ + return m_tunnelPartnerTile; +} + +void BuildModeController::setTunnelGhost(BuildingType resolvedType, + std::optional partnerTile) +{ + m_tunnelGhostType = resolvedType; + m_tunnelPartnerTile = std::move(partnerTile); +} + +bool BuildModeController::isDraggingBelt() const +{ + return m_draggingBelt; +} + +QPoint BuildModeController::getBeltDragAnchor() const +{ + return m_beltDragAnchor; +} + +const std::vector& BuildModeController::getBeltDragPath() const +{ + return m_beltDragPath; +} + +void BuildModeController::beginBeltDrag(QPoint anchorTile) +{ + m_draggingBelt = true; + m_beltDragAnchor = anchorTile; +} + +void BuildModeController::setBeltDragPath(std::vector path) +{ + m_beltDragPath = std::move(path); +} + +void BuildModeController::cancelBeltDrag() +{ + m_draggingBelt = false; + m_beltDragPath.clear(); +} + +const Blueprint& BuildModeController::getBlueprint() const +{ + return m_blueprint; +} + +Blueprint& BuildModeController::getMutableBlueprint() +{ + return m_blueprint; +} + +QPoint BuildModeController::getBlueprintGhostTile() const +{ + return m_blueprintGhostTile; +} + +void BuildModeController::setBlueprintGhostTile(QPoint tile) +{ + m_blueprintGhostTile = tile; +} + +const std::optional& +BuildModeController::getDeconstructHoverBuildingId() const +{ + return m_deconstructHoverBuildingId; +} + +void BuildModeController::setDeconstructHoverBuildingId(std::optional id) +{ + m_deconstructHoverBuildingId = std::move(id); +} diff --git a/src/lib/core/BuildModeController.h b/src/lib/core/BuildModeController.h new file mode 100644 index 0000000..5bc5a6d --- /dev/null +++ b/src/lib/core/BuildModeController.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include + +#include + +#include "BeltDragPath.h" +#include "Blueprint.h" +#include "BuildingId.h" +#include "BuildingType.h" +#include "Rotation.h" + +// Which of the mutually exclusive world-interaction modes is active +// (REQ-UI-HOTKEYS, REQ-BLD-GHOST, REQ-UI-BLUEPRINT-PLACE, REQ-BLD-DECONSTRUCT). +enum class BuildMode +{ + None, // plain selection + Builder, // placing one building type, ghost following the cursor + Blueprint, // placing a saved multi-building layout + Deconstruct // marking buildings for demolition +}; + +// The active build mode and the transient state that belongs to it. +// +// These modes were previously three independent flags, and every entry point +// cleared the other two by hand — inconsistently, which is how entering builder +// mode came to drop a blueprint without announcing it. Here exclusivity is +// structural: one mode is active, and every transition runs through enterMode(), +// which exits whatever was active first and publishes the same events regardless +// of which way the player got there. +// +// Everything needing the simulation — placement validity, tunnel matching, belt +// path building — stays with the caller, which computes and hands back the result +// (setGhostValidity, setTunnelGhost, setBeltDragPath). That keeps this a plain +// value that can be tested without a world. +class BuildModeController +{ +public: + BuildMode getMode() const; + bool isBuilderMode() const; + bool isBlueprintMode() const; + bool isDeconstructMode() const; + + // --- transitions ---------------------------------------------------------- + // Each leaves the previously active mode with its proper exit event. + void enterBuilderMode(BuildingType type); + void enterBlueprintMode(Blueprint blueprint); + // Leaves deconstruct mode if it is active, enters it otherwise + // (REQ-BLD-DECONSTRUCT-CLICK). + void toggleDeconstructMode(); + void exitBuilderMode(); + void exitBlueprintMode(); + // Backs out of whichever mode is active, if any (the Q key and right-click). + void exitCurrentMode(); + + // --- builder mode --------------------------------------------------------- + // Only meaningful while isBuilderMode(). + BuildingType getBuilderType() const; + // True while the builder type is TunnelEntry, where the ghost resolves to an + // entry or an exit by hovered position (REQ-BLD-TUNNEL-MODE). + bool isTunnelMode() const; + // The type the ghost currently represents: the position-resolved tunnel type in + // tunnel mode, the plain builder type otherwise. + BuildingType getEffectiveBuilderType() const; + + QPoint getGhostTile() const; + Rotation getGhostRotation() const; + bool isGhostValid() const; + void setGhostTile(QPoint tile); + void setGhostValidity(bool valid); + // Turns the ghost one quarter turn. Validity is not rechecked here; the caller + // does that and calls setGhostValidity, because only it can see the world. + void rotateGhost(bool clockwise); + + BuildingType getTunnelGhostType() const; + const std::optional& getTunnelPartnerTile() const; + void setTunnelGhost(BuildingType resolvedType, std::optional partnerTile); + + // --- belt drag placement (REQ-BLD-BELT-DRAG) ------------------------------ + bool isDraggingBelt() const; + QPoint getBeltDragAnchor() const; + const std::vector& getBeltDragPath() const; + void beginBeltDrag(QPoint anchorTile); + void setBeltDragPath(std::vector path); + // Drops the drag without placing anything, staying in builder mode. + void cancelBeltDrag(); + + // --- blueprint mode ------------------------------------------------------- + // Only meaningful while isBlueprintMode(). + const Blueprint& getBlueprint() const; + // Mutable so the caller can rotate the layout in place; rotating a blueprint + // needs building footprints from the config, which does not belong here. + Blueprint& getMutableBlueprint(); + QPoint getBlueprintGhostTile() const; + void setBlueprintGhostTile(QPoint tile); + + // --- deconstruct mode ----------------------------------------------------- + const std::optional& getDeconstructHoverBuildingId() const; + void setDeconstructHoverBuildingId(std::optional id); + +private: + // The single transition point: leaves the active mode, then enters `mode`. + void enterMode(BuildMode mode); + + BuildMode m_mode = BuildMode::None; + + BuildingType m_builderType = BuildingType::Belt; + QPoint m_ghostTile; + Rotation m_ghostRotation = Rotation::East; + bool m_ghostValid = false; + BuildingType m_tunnelGhostType = BuildingType::TunnelEntry; + std::optional m_tunnelPartnerTile; + + bool m_draggingBelt = false; + QPoint m_beltDragAnchor; + std::vector m_beltDragPath; + + Blueprint m_blueprint; + QPoint m_blueprintGhostTile; + + std::optional m_deconstructHoverBuildingId; +}; diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index c741b00..f4db9e6 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -17,6 +17,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h + ${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h PARENT_SCOPE ) @@ -31,6 +32,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp PARENT_SCOPE ) diff --git a/src/test/BuildModeControllerTest.cpp b/src/test/BuildModeControllerTest.cpp new file mode 100644 index 0000000..e929f1c --- /dev/null +++ b/src/test/BuildModeControllerTest.cpp @@ -0,0 +1,301 @@ +#include "catch.hpp" + +#include + +#include "BlueprintModeExitedEvent.h" +#include "BuildModeController.h" +#include "BuilderModeExitedEvent.h" +#include "DeconstructModeChangedEvent.h" +#include "EventHandler.h" +#include "EventManager.h" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Records the mode events, which are the half of this class's contract that the +// panels depend on: a mode that ends without announcing it leaves its button +// stuck highlighted. +class ModeEventSpy : public CombinedEventHandler +{ +public: + ModeEventSpy() { registerForEvents(); } + ~ModeEventSpy() { unregisterForEvents(); } + + int builderExits = 0; + int blueprintExits = 0; + int deconstructChanges = 0; + bool lastDeconstructActive = false; + +private: + void handleEvent(std::shared_ptr /*event*/) override + { + ++builderExits; + } + void handleEvent(std::shared_ptr /*event*/) override + { + ++blueprintExits; + } + void handleEvent(std::shared_ptr event) override + { + ++deconstructChanges; + lastDeconstructActive = event->active; + } +}; + +static Blueprint makeBlueprint() +{ + Blueprint blueprint; + BlueprintBuilding building; + building.type = BuildingType::Belt; + building.offset = QPoint(0, 0); + building.rotation = Rotation::East; + blueprint.buildings.push_back(building); + return blueprint; +} + +// --------------------------------------------------------------------------- +// Exclusivity +// --------------------------------------------------------------------------- + +TEST_CASE("Only one mode is active at a time", "[buildmode]") +{ + BuildModeController controller; + REQUIRE(controller.getMode() == BuildMode::None); + + controller.enterBuilderMode(BuildingType::Belt); + REQUIRE(controller.isBuilderMode()); + REQUIRE_FALSE(controller.isBlueprintMode()); + REQUIRE_FALSE(controller.isDeconstructMode()); + + controller.enterBlueprintMode(makeBlueprint()); + REQUIRE(controller.isBlueprintMode()); + REQUIRE_FALSE(controller.isBuilderMode()); + + controller.toggleDeconstructMode(); + REQUIRE(controller.isDeconstructMode()); + REQUIRE_FALSE(controller.isBlueprintMode()); +} + +TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]") +{ + // Regression: entering builder mode used to drop the blueprint silently, so the + // blueprint panel kept its button highlighted for a mode that was over. + BuildModeController controller; + controller.enterBlueprintMode(makeBlueprint()); + + ModeEventSpy spy; + controller.enterBuilderMode(BuildingType::Belt); + + REQUIRE(spy.blueprintExits == 1); +} + +TEST_CASE("Every mode announces its exit however it is left", "[buildmode]") +{ + // The point of routing all transitions through one place: which mode the player + // switches to must not change what the mode they left announces. + SECTION("builder, left for a blueprint") + { + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + ModeEventSpy spy; + controller.enterBlueprintMode(makeBlueprint()); + REQUIRE(spy.builderExits == 1); + } + SECTION("builder, left for deconstruct") + { + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + ModeEventSpy spy; + controller.toggleDeconstructMode(); + REQUIRE(spy.builderExits == 1); + } + SECTION("blueprint, left for deconstruct") + { + BuildModeController controller; + controller.enterBlueprintMode(makeBlueprint()); + ModeEventSpy spy; + controller.toggleDeconstructMode(); + REQUIRE(spy.blueprintExits == 1); + } + SECTION("deconstruct, left for builder") + { + BuildModeController controller; + controller.toggleDeconstructMode(); + ModeEventSpy spy; + controller.enterBuilderMode(BuildingType::Belt); + REQUIRE(spy.deconstructChanges == 1); + REQUIRE_FALSE(spy.lastDeconstructActive); + } +} + +TEST_CASE("Switching between builder types stays in builder mode", "[buildmode]") +{ + // Picking a different building is not leaving the mode, so nothing is announced. + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + + ModeEventSpy spy; + controller.enterBuilderMode(BuildingType::Splitter); + + REQUIRE(controller.getBuilderType() == BuildingType::Splitter); + REQUIRE(spy.builderExits == 0); +} + +TEST_CASE("Exiting a mode that is not active does nothing", "[buildmode]") +{ + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + + ModeEventSpy spy; + controller.exitBlueprintMode(); + + REQUIRE(controller.isBuilderMode()); + REQUIRE(spy.blueprintExits == 0); + REQUIRE(spy.builderExits == 0); +} + +TEST_CASE("Deconstruct mode toggles off and announces both edges", "[buildmode]") +{ + BuildModeController controller; + + ModeEventSpy spy; + controller.toggleDeconstructMode(); + REQUIRE(controller.isDeconstructMode()); + REQUIRE(spy.lastDeconstructActive); + + controller.toggleDeconstructMode(); + REQUIRE(controller.getMode() == BuildMode::None); + REQUIRE_FALSE(spy.lastDeconstructActive); + REQUIRE(spy.deconstructChanges == 2); +} + +TEST_CASE("Leaving the current mode works from any of them", "[buildmode]") +{ + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + controller.exitCurrentMode(); + REQUIRE(controller.getMode() == BuildMode::None); + + controller.enterBlueprintMode(makeBlueprint()); + controller.exitCurrentMode(); + REQUIRE(controller.getMode() == BuildMode::None); + + controller.toggleDeconstructMode(); + controller.exitCurrentMode(); + REQUIRE(controller.getMode() == BuildMode::None); +} + +// --------------------------------------------------------------------------- +// State cleared on transition +// --------------------------------------------------------------------------- + +TEST_CASE("Leaving builder mode drops an in-progress belt drag", "[buildmode]") +{ + // A drag surviving the mode change would place belts on the next release. + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + controller.beginBeltDrag(QPoint(3, 4)); + controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}}); + REQUIRE(controller.isDraggingBelt()); + + controller.toggleDeconstructMode(); + + REQUIRE_FALSE(controller.isDraggingBelt()); + REQUIRE(controller.getBeltDragPath().empty()); +} + +TEST_CASE("Leaving deconstruct mode drops the hovered building", "[buildmode]") +{ + BuildModeController controller; + controller.toggleDeconstructMode(); + controller.setDeconstructHoverBuildingId(BuildingId(4)); + + controller.enterBuilderMode(BuildingType::Belt); + + REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value()); +} + +TEST_CASE("Entering builder mode resets the ghost", "[buildmode]") +{ + // A fresh builder starts facing East and invalid until the first hover, rather + // than inheriting the previous building's facing (REQ-BLD-GHOST). + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + controller.rotateGhost(true); + controller.setGhostValidity(true); + + controller.enterBuilderMode(BuildingType::Splitter); + + REQUIRE(controller.getGhostRotation() == Rotation::East); + REQUIRE_FALSE(controller.isGhostValid()); +} + +TEST_CASE("Cancelling a belt drag stays in builder mode", "[buildmode]") +{ + // Right-click during a drag abandons the path but keeps the belt selected + // (REQ-BLD-BELT-DRAG). + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + controller.beginBeltDrag(QPoint(1, 1)); + + ModeEventSpy spy; + controller.cancelBeltDrag(); + + REQUIRE(controller.isBuilderMode()); + REQUIRE_FALSE(controller.isDraggingBelt()); + REQUIRE(spy.builderExits == 0); +} + +// --------------------------------------------------------------------------- +// Ghost and tunnel state +// --------------------------------------------------------------------------- + +TEST_CASE("Rotating the ghost cycles through the four facings", "[buildmode]") +{ + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + + controller.rotateGhost(true); + REQUIRE(controller.getGhostRotation() == Rotation::South); + controller.rotateGhost(true); + REQUIRE(controller.getGhostRotation() == Rotation::West); + controller.rotateGhost(false); + REQUIRE(controller.getGhostRotation() == Rotation::South); +} + +TEST_CASE("Tunnel mode is the tunnel entry builder type", "[buildmode]") +{ + // REQ-BLD-TUNNEL-MODE: one builder type covers both tunnel ends. + BuildModeController controller; + controller.enterBuilderMode(BuildingType::Belt); + REQUIRE_FALSE(controller.isTunnelMode()); + + controller.enterBuilderMode(BuildingType::TunnelEntry); + REQUIRE(controller.isTunnelMode()); +} + +TEST_CASE("The effective builder type follows the resolved tunnel end", "[buildmode]") +{ + BuildModeController controller; + controller.enterBuilderMode(BuildingType::TunnelEntry); + REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry); + + controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5)); + REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelExit); + REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5)); +} + +TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]") +{ + BuildModeController controller; + controller.enterBuilderMode(BuildingType::TunnelEntry); + controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5)); + + controller.enterBuilderMode(BuildingType::Belt); + + REQUIRE(controller.getEffectiveBuilderType() == BuildingType::Belt); + REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value()); +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index f9a432b..a22afb8 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -15,6 +15,7 @@ add_files( WorldCoordinatesTest.cpp WorldCameraTest.cpp SelectionControllerTest.cpp + BuildModeControllerTest.cpp BuildingTest.cpp BuildingConfigTest.cpp ShipTest.cpp diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index d0fc267..755e82c 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -199,10 +199,6 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, , m_gameSpeedMultiplier(1.0) , m_prevNonZeroSpeed(1.0) , m_camera(config->world.scroll, config->world.regions) - , m_ghostRotation(Rotation::East) - , m_ghostValid(false) - , m_dragging(false) - , m_deconstructMode(false) , m_debugDraw(false) , m_rng(std::random_device{}()) , m_boxSelecting(false) @@ -732,7 +728,7 @@ void GameWorldView::stepSpeed(int delta) void GameWorldView::placeBlueprintAtTile(QPoint center) { - const Blueprint& bp = *m_blueprintMode; + const Blueprint& bp = m_buildMode.getBlueprint(); for (const BlueprintBuilding& bb : bp.buildings) { @@ -819,16 +815,6 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) } } -bool GameWorldView::inTunnelMode() const -{ - return m_builderType.has_value() && *m_builderType == BuildingType::TunnelEntry; -} - -BuildingType GameWorldView::effectiveBuilderType() const -{ - return inTunnelMode() ? m_tunnelGhostType : *m_builderType; -} - TunnelTileMap GameWorldView::collectTunnelTiles() const { // Index every tunnel entry/exit — built or still a construction site — by its @@ -864,13 +850,11 @@ TunnelLookup GameWorldView::makeTunnelLookup(const TunnelTileMap& tunnels) void GameWorldView::updateTunnelGhost() { - m_tunnelGhostType = BuildingType::TunnelEntry; - m_tunnelPartnerTile.reset(); - // The connection preview and entry/exit switch only apply at a valid placement // (REQ-BLD-TUNNEL-MODE); at an invalid position the ghost stays a plain entry. - if (!m_ghostValid) + if (!m_buildMode.isGhostValid()) { + m_buildMode.setTunnelGhost(BuildingType::TunnelEntry, std::nullopt); return; } @@ -878,33 +862,34 @@ void GameWorldView::updateTunnelGhost() const TunnelLookup lookup = makeTunnelLookup(tunnels); const TunnelCompletion completion = - resolveTunnelCompletion(lookup, m_ghostTile, m_ghostRotation, + resolveTunnelCompletion(lookup, m_buildMode.getGhostTile(), + m_buildMode.getGhostRotation(), m_config->world.tunnelMaxDistance_tiles, m_cursorWorldPos); - m_tunnelGhostType = completion.resolvedType; - m_tunnelPartnerTile = completion.partnerTile; + m_buildMode.setTunnelGhost(completion.resolvedType, completion.partnerTile); } void GameWorldView::placeAtTile(QPoint tile) { - if (!m_builderType.has_value()) + if (!m_buildMode.isBuilderMode()) { return; } - const BuildingType type = effectiveBuilderType(); + const BuildingType type = m_buildMode.getEffectiveBuilderType(); + const Rotation rotation = m_buildMode.getGhostRotation(); - if (!isValidPlacement(type, tile, m_ghostRotation)) + if (!isValidPlacement(type, tile, rotation)) { return; } const std::optional rotateTarget = - findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, tile, m_ghostRotation); + findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), type, tile, rotation); if (rotateTarget.has_value()) { std::shared_ptr command = std::make_shared(); command->id = *rotateTarget; - command->newRotation = m_ghostRotation; + command->newRotation = rotation; enqueueCommand(command); return; } @@ -920,12 +905,12 @@ void GameWorldView::placeAtTile(QPoint tile) { if (!isTileOccupied(m_sim->getFactoryState(), tile) && canAfford(type)) { - enqueuePlaceBuilding(type, tile, m_ghostRotation); + enqueuePlaceBuilding(type, tile, rotation); } } else { - enqueuePlaceBuilding(type, tile, m_ghostRotation); + enqueuePlaceBuilding(type, tile, rotation); } } @@ -983,26 +968,28 @@ void GameWorldView::recomputeBeltDragPath(QPoint cursorTile) } } - m_beltDragPath = computeBeltDragPath(m_beltDragAnchor, endTile, m_ghostRotation); - if (forcedEndRotation.has_value() && !m_beltDragPath.empty()) + std::vector path = computeBeltDragPath( + m_buildMode.getBeltDragAnchor(), endTile, m_buildMode.getGhostRotation()); + if (forcedEndRotation.has_value() && !path.empty()) { // The end tile points into the target, overriding its incoming-step // orientation (REQ-BLD-BELT-DRAG "Snapping to a building"). - m_beltDragPath.back().rotation = *forcedEndRotation; + path.back().rotation = *forcedEndRotation; } + m_buildMode.setBeltDragPath(std::move(path)); } std::vector GameWorldView::resolveBeltDragPath() const { std::vector resolved; - resolved.reserve(m_beltDragPath.size()); + resolved.reserve(m_buildMode.getBeltDragPath().size()); const BuildingDef* def = m_config->buildings.findBuildingDef(BuildingType::Belt); const int beltCost = (def != nullptr) ? def->cost : 0; const int stock = m_sim->getBuildingBlocksStock(); int spent = 0; - for (const BeltPathTile& entry : m_beltDragPath) + for (const BeltPathTile& entry : m_buildMode.getBeltDragPath()) { BeltDragResolved item; const std::optional rotateTarget = @@ -1040,7 +1027,7 @@ void GameWorldView::applyBeltDragPath() for (std::size_t index = 0; index < resolved.size(); ++index) { const BeltDragResolved& item = resolved[index]; - const BeltPathTile& entry = m_beltDragPath[index]; + const BeltPathTile& entry = m_buildMode.getBeltDragPath()[index]; if (item.action == BeltTileAction::PlaceNew && item.affordable) { enqueuePlaceBuilding(BuildingType::Belt, entry.tile, entry.rotation); @@ -1927,9 +1914,10 @@ void GameWorldView::drawOverlays(QPainter& painter, const WorldCoordinates& coor drawSelectedTunnelConnections(painter, coordinates); // Builder-mode ghost - if (m_builderType.has_value()) + if (m_buildMode.isBuilderMode()) { - if (*m_builderType == BuildingType::Belt && m_dragging) + if (m_buildMode.getBuilderType() == BuildingType::Belt + && m_buildMode.isDraggingBelt()) { // Belt drag: a ghost per path tile (REQ-BLD-BELT-DRAG). Rotate-in-place // and affordable new tiles use the belt colors; occupied/invalid tiles @@ -1942,7 +1930,7 @@ void GameWorldView::drawOverlays(QPainter& painter, const WorldCoordinates& coor { continue; } - const BeltPathTile& entry = m_beltDragPath[index]; + const BeltPathTile& entry = m_buildMode.getBeltDragPath()[index]; drawBuildingGhost(painter, coordinates, BuildingType::Belt, entry.tile, entry.rotation, /*valid*/ item.action != BeltTileAction::Invalid, @@ -1955,37 +1943,42 @@ void GameWorldView::drawOverlays(QPainter& painter, const WorldCoordinates& coor // exit) and, when it would complete an existing tunnel, the matched end // and the tiles between it and the ghost are tinted green // (REQ-BLD-TUNNEL-MODE). - if (inTunnelMode() && m_ghostValid && m_tunnelPartnerTile.has_value()) + const QPoint ghostTile = m_buildMode.getGhostTile(); + const std::optional& partnerTile = m_buildMode.getTunnelPartnerTile(); + if (m_buildMode.isTunnelMode() && m_buildMode.isGhostValid() + && partnerTile.has_value()) { const QColor green = m_visuals->overlays.tunnelPreview; - painter.fillRect(coordinates.tileRect(*m_tunnelPartnerTile), green); + painter.fillRect(coordinates.tileRect(*partnerTile), green); // Partner and ghost tile are colinear along the tunnel run; tint the // tiles strictly between them. - const QPoint delta = *m_tunnelPartnerTile - m_ghostTile; + const QPoint delta = *partnerTile - ghostTile; const QPoint step((delta.x() > 0) - (delta.x() < 0), (delta.y() > 0) - (delta.y() < 0)); - for (QPoint t = m_ghostTile + step; t != *m_tunnelPartnerTile; t += step) + for (QPoint t = ghostTile + step; t != *partnerTile; t += step) { painter.fillRect(coordinates.tileRect(t), green); } } - drawBuildingGhost(painter, coordinates, effectiveBuilderType(), - m_ghostTile, m_ghostRotation, m_ghostValid, + drawBuildingGhost(painter, coordinates, + m_buildMode.getEffectiveBuilderType(), + ghostTile, m_buildMode.getGhostRotation(), + m_buildMode.isGhostValid(), /*showPortTargetGlyphs*/ true); } } // Blueprint placement ghost - if (m_blueprintMode.has_value()) + if (m_buildMode.isBlueprintMode()) { - for (const BlueprintBuilding& bb : m_blueprintMode->buildings) + for (const BlueprintBuilding& bb : m_buildMode.getBlueprint().buildings) { // Locked building types are omitted from the blueprint (REQ-LOCK-BUILDING, // REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either. if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } - const QPoint anchor = m_blueprintGhostTile + bb.offset; + const QPoint anchor = m_buildMode.getBlueprintGhostTile() + bb.offset; const bool valid = isValidPlacement(bb.type, anchor, bb.rotation); drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation, valid, /*showPortTargetGlyphs*/ false); @@ -2006,7 +1999,7 @@ void GameWorldView::drawOverlays(QPainter& painter, const WorldCoordinates& coor // Deconstruct tint: while dragging a deconstruct box, tint every covered // building/site (REQ-BLD-DECONSTRUCT-BOX); otherwise tint the hovered one. - if (m_deconstructMode && m_boxSelecting) + if (m_buildMode.isDeconstructMode() && m_boxSelecting) { for (BuildingId id : buildingsInBox(m_boxStartTile, m_boxCurrentTile)) { @@ -2026,9 +2019,11 @@ void GameWorldView::drawOverlays(QPainter& painter, const WorldCoordinates& coor } } } - else if (m_deconstructMode && m_deconstructHoverBuildingId.has_value()) + else if (m_buildMode.isDeconstructMode() + && m_buildMode.getDeconstructHoverBuildingId().has_value()) { - const Building* b = findBuilding(m_sim->getFactoryState(), *m_deconstructHoverBuildingId); + const Building* b = findBuilding(m_sim->getFactoryState(), + *m_buildMode.getDeconstructHoverBuildingId()); if (b) { for (const QPoint& cell : b->bodyCells) @@ -2151,7 +2146,7 @@ void GameWorldView::drawPauseBorder(QPainter& painter) void GameWorldView::drawDeconstructBorder(QPainter& painter) { - if (!m_deconstructMode) { return; } + if (!m_buildMode.isDeconstructMode()) { return; } // Reuse the deconstruct overlay color (with its configured alpha) as the edge color. drawVignetteBorder(painter, m_visuals->overlays.deconstructTint); } @@ -2322,22 +2317,16 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) { if (event->button() == Qt::RightButton) { - if (m_builderType.has_value()) + if (m_buildMode.isBuilderMode() && m_buildMode.isDraggingBelt()) { - if (m_dragging) - { - // Cancel the in-progress belt drag without placing anything; - // stay in belt builder mode (REQ-BLD-BELT-DRAG). - m_dragging = false; - m_beltDragPath.clear(); - } - else - { - exitBuilderMode(); - } + // Cancel the in-progress belt drag without placing anything; + // stay in belt builder mode (REQ-BLD-BELT-DRAG). + m_buildMode.cancelBeltDrag(); + } + else if (m_buildMode.getMode() != BuildMode::None) + { + m_buildMode.exitCurrentMode(); } - else if (m_blueprintMode.has_value()) { exitBlueprintMode(); } - else if (m_deconstructMode) { toggleDeconstructMode(); } else if (event->modifiers() & Qt::ShiftModifier) { // Shift + right-click copies a building's settings, but only in the @@ -2353,15 +2342,13 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) const QPoint tile = coordinates.widgetToTile(event->pos()); - if (m_builderType.has_value()) + if (m_buildMode.isBuilderMode()) { - const BuildingType type = *m_builderType; - if (type == BuildingType::Belt) + if (m_buildMode.getBuilderType() == BuildingType::Belt) { // Deferred placement: start the drag and show the path ghost; nothing // is placed until release (REQ-BLD-BELT-DRAG). - m_dragging = true; - m_beltDragAnchor = tile; + m_buildMode.beginBeltDrag(tile); m_cursorWorldPos = coordinates.widgetToWorld(event->pos()); recomputeBeltDragPath(tile); } @@ -2370,11 +2357,11 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) placeAtTile(tile); } } - else if (m_blueprintMode.has_value()) + else if (m_buildMode.isBlueprintMode()) { placeBlueprintAtTile(tile); } - else if (m_deconstructMode) + else if (m_buildMode.isDeconstructMode()) { // Start a deconstruct box drag; a plain click resolves as a 1x1 box on // release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX). @@ -2478,32 +2465,34 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event) const QPoint tile = coordinates.widgetToTile(event->pos()); m_cursorWorldPos = coordinates.widgetToWorld(event->pos()); - if (m_builderType.has_value()) + if (m_buildMode.isBuilderMode()) { - m_ghostTile = tile; - m_ghostValid = isValidPlacement(*m_builderType, tile, m_ghostRotation); + m_buildMode.setGhostTile(tile); + m_buildMode.setGhostValidity( + isValidPlacement(m_buildMode.getBuilderType(), tile, + m_buildMode.getGhostRotation())); - if (inTunnelMode()) + if (m_buildMode.isTunnelMode()) { // Resolve entry vs exit and the completion partner for the new hover // position and sub-tile cursor (REQ-BLD-TUNNEL-MODE). updateTunnelGhost(); } - if (m_dragging) + if (m_buildMode.isDraggingBelt()) { // Belt drag: update the previewed path; placement happens on release // (REQ-BLD-BELT-DRAG). recomputeBeltDragPath(tile); } } - else if (m_blueprintMode.has_value()) + else if (m_buildMode.isBlueprintMode()) { - m_blueprintGhostTile = tile; + m_buildMode.setBlueprintGhostTile(tile); } - else if (m_deconstructMode) + else if (m_buildMode.isDeconstructMode()) { - m_deconstructHoverBuildingId = buildingAtTile(tile); + m_buildMode.setDeconstructHoverBuildingId(buildingAtTile(tile)); if (m_boxSelecting) { m_boxCurrentTile = tile; } } else if (m_boxSelecting) @@ -2516,13 +2505,12 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) { if (event->button() != Qt::LeftButton) { return; } - if (m_dragging) + if (m_buildMode.isDraggingBelt()) { // Apply the previewed belt path now that the button is released // (REQ-BLD-BELT-DRAG). applyBeltDragPath(); - m_dragging = false; - m_beltDragPath.clear(); + m_buildMode.cancelBeltDrag(); } if (m_boxSelecting) @@ -2532,7 +2520,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) const std::vector boxIds = buildingsInBox(m_boxStartTile, m_boxCurrentTile); - if (m_deconstructMode) + if (m_buildMode.isDeconstructMode()) { const FactoryState& factory = m_sim->getFactoryState(); @@ -2590,7 +2578,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) } } - m_deconstructHoverBuildingId = std::nullopt; + m_buildMode.setDeconstructHoverBuildingId(std::nullopt); return; } @@ -2602,39 +2590,26 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) // Methods (formerly slots) // --------------------------------------------------------------------------- -void GameWorldView::toggleDeconstructMode() -{ - if (m_deconstructMode) - { - m_deconstructMode = false; - m_deconstructHoverBuildingId = std::nullopt; - } - else - { - if (m_builderType.has_value()) { exitBuilderMode(); } - if (m_blueprintMode.has_value()) { exitBlueprintMode(); } - m_deconstructMode = true; - } - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_deconstructMode)); -} - void GameWorldView::rotateGhost(bool clockwise) { - if (m_builderType.has_value()) + if (m_buildMode.isBuilderMode()) { - m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation) - : rotateCounterClockwise(m_ghostRotation); - m_ghostValid = isValidPlacement(*m_builderType, m_ghostTile, m_ghostRotation); + m_buildMode.rotateGhost(clockwise); + m_buildMode.setGhostValidity( + isValidPlacement(m_buildMode.getBuilderType(), m_buildMode.getGhostTile(), + m_buildMode.getGhostRotation())); // A new facing changes which tunnels the ghost could complete (REQ-BLD-TUNNEL-MODE). - if (inTunnelMode()) { updateTunnelGhost(); } + if (m_buildMode.isTunnelMode()) { updateTunnelGhost(); } // Rotating during a belt drag re-picks the path's primary axis immediately, // without waiting for the next mouse move (REQ-BLD-BELT-DRAG). - if (m_dragging) { recomputeBeltDragPath(m_ghostTile); } + if (m_buildMode.isDraggingBelt()) + { + recomputeBeltDragPath(m_buildMode.getGhostTile()); + } } - else if (m_blueprintMode.has_value()) + else if (m_buildMode.isBlueprintMode()) { - for (BlueprintBuilding& bb : m_blueprintMode->buildings) + for (BlueprintBuilding& bb : m_buildMode.getMutableBlueprint().buildings) { const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type); if (!def) { continue; } @@ -2740,45 +2715,6 @@ void GameWorldView::pasteConfigTo(BuildingId id) } } -void GameWorldView::enterBuilderMode(BuildingType type) -{ - m_builderType = type; - m_ghostRotation = Rotation::East; - m_ghostValid = false; - m_tunnelGhostType = BuildingType::TunnelEntry; - m_tunnelPartnerTile.reset(); - m_deconstructMode = false; - m_blueprintMode.reset(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared(false)); -} - -void GameWorldView::enterBlueprintMode(Blueprint blueprint) -{ - if (m_builderType.has_value()) { exitBuilderMode(); } - m_deconstructMode = false; - EventManager::getInstance()->sendEventImmediately( - std::make_shared(false)); - m_blueprintGhostTile = m_ghostTile; - m_blueprintMode = std::move(blueprint); -} - -void GameWorldView::exitBlueprintMode() -{ - m_blueprintMode.reset(); - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); -} - -void GameWorldView::exitBuilderMode() -{ - m_builderType.reset(); - m_beltDragPath.clear(); - m_dragging = false; - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); -} - double GameWorldView::getGameSpeed() const { return m_gameSpeedMultiplier; @@ -2803,16 +2739,11 @@ void GameWorldView::setGameSpeed(double multiplier) void GameWorldView::resetForNewGame() { - exitBuilderMode(); - exitBlueprintMode(); + // Leaves whichever mode was active, announcing that one exit rather than all + // three; a mode that was not active has nothing to announce. + m_buildMode.exitCurrentMode(); m_activeBeams.clear(); m_schematicChoiceShown = false; - m_ghostRotation = Rotation::East; - m_ghostValid = false; - m_deconstructMode = false; - m_deconstructHoverBuildingId = std::nullopt; - EventManager::getInstance()->sendEventImmediately( - std::make_shared(false)); m_selection.clearAll(); m_copiedConfig = std::nullopt; m_copyConfigFlashes.clear(); @@ -2879,27 +2810,27 @@ void GameWorldView::handleEvent(std::shared_ptr event) void GameWorldView::handleEvent(std::shared_ptr event) { - enterBuilderMode(event->type); + m_buildMode.enterBuilderMode(event->type); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { - exitBuilderMode(); + m_buildMode.exitBuilderMode(); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { - toggleDeconstructMode(); + m_buildMode.toggleDeconstructMode(); } void GameWorldView::handleEvent(std::shared_ptr event) { - enterBlueprintMode(event->blueprint); + m_buildMode.enterBlueprintMode(event->blueprint); } void GameWorldView::handleEvent(std::shared_ptr /*event*/) { - exitBlueprintMode(); + m_buildMode.exitBlueprintMode(); } void GameWorldView::handleEvent(std::shared_ptr event) @@ -2939,9 +2870,10 @@ void GameWorldView::handleEvent(std::shared_ptr { // One key backs out of whichever mode is active, and enters deconstruct mode // when none is (REQ-UI-HOTKEYS). - if (m_builderType.has_value()) { exitBuilderMode(); } - else if (m_blueprintMode.has_value()) { exitBlueprintMode(); } - else { toggleDeconstructMode(); } + // One key backs out of whichever mode is active, and enters deconstruct mode + // when none is (REQ-UI-HOTKEYS). + if (m_buildMode.getMode() == BuildMode::None) { m_buildMode.toggleDeconstructMode(); } + else { m_buildMode.exitCurrentMode(); } } void GameWorldView::handleEvent(std::shared_ptr /*event*/) diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 8d07163..79b00d2 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -19,6 +19,7 @@ #include #include "Blueprint.h" +#include "BuildModeController.h" #include "BuildingConfig.h" #include "BlueprintModeExitedEvent.h" #include "BlueprintPlacementRequestedEvent.h" @@ -262,14 +263,9 @@ private: void stepSpeed(int delta); void placeAtTile(QPoint tile); - // Unified tunnel build mode (REQ-BLD-TUNNEL-MODE). Active while the builder type - // is TunnelEntry; the ghost then resolves to an entry or exit by hovered position. - bool inTunnelMode() const; - // The building type the ghost currently represents: the position-resolved tunnel - // type in tunnel mode, otherwise the plain builder type. - BuildingType effectiveBuilderType() const; - // Recomputes m_tunnelGhostType and m_tunnelPartnerTile from the current ghost - // tile, rotation, and sub-tile cursor position. Only meaningful in tunnel mode. + // Re-resolves the tunnel ghost type and completion partner from the current + // ghost tile, rotation, and sub-tile cursor position, storing both on the build + // mode controller. Only meaningful in tunnel mode (REQ-BLD-TUNNEL-MODE). void updateTunnelGhost(); // Indexes every tunnel entry/exit — built or still a construction site — by its // single-cell tile. Shared by the placement preview and the selection highlight. @@ -291,8 +287,8 @@ private: bool affordable; // meaningful only for PlaceNew std::optional rotateId; // set only for RotateInPlace }; - // Recomputes m_beltDragPath from m_beltDragAnchor to cursorTile using the - // current ghost orientation. + // Recomputes the drag path from its anchor to cursorTile using the current ghost + // orientation, and stores it on the build mode controller. void recomputeBeltDragPath(QPoint cursorTile); // Classifies each path tile against the current sim state, applying cumulative // affordability to the PlaceNew tiles. @@ -306,11 +302,9 @@ private: void copyConfigFrom(BuildingId id); void pasteConfigTo(BuildingId id); - void enterBuilderMode(BuildingType type); - void exitBuilderMode(); - void enterBlueprintMode(Blueprint blueprint); - void exitBlueprintMode(); - void toggleDeconstructMode(); + // Turns the ghost and refreshes everything that depends on its facing: placement + // validity, the tunnel completion match, and an in-progress belt drag's path. + // The mode transitions themselves live on m_buildMode. void rotateGhost(bool clockwise); struct ActiveBeam @@ -364,28 +358,15 @@ private: std::vector m_activeBeams; - std::optional m_builderType; - Rotation m_ghostRotation; - QPoint m_ghostTile; - bool m_ghostValid; - // Unified tunnel build mode (REQ-BLD-TUNNEL-MODE): while m_builderType is - // TunnelEntry the ghost resolves to an entry or an exit based on the hovered - // position; m_tunnelPartnerTile is the existing tunnel it would complete (drawn - // as the green connection preview), or unset when there is no completion match. - BuildingType m_tunnelGhostType = BuildingType::TunnelEntry; - std::optional m_tunnelPartnerTile; - // Deferred belt drag placement (REQ-BLD-BELT-DRAG): while dragging, the - // rectilinear anchor->cursor path is recomputed on each move and only applied - // on release. Empty unless a belt drag is in progress. - std::vector m_beltDragPath; - QPoint m_beltDragAnchor; - // Last known cursor position in world (tile) units; used to pick the belt-drag - // end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG). - QVector2D m_cursorWorldPos; - bool m_dragging; + // The active build mode (builder / blueprint / deconstruct, or none) and the + // ghost, tunnel and belt-drag state belonging to it. Owns the transitions + // between them; this widget supplies the parts that need the simulation. + BuildModeController m_buildMode; - std::optional m_blueprintMode; - QPoint m_blueprintGhostTile; + // Last known cursor position in world (tile) units; used to pick the belt-drag + // end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG) + // and to resolve the tunnel ghost sub-tile (REQ-BLD-TUNNEL-MODE). + QVector2D m_cursorWorldPos; // Temporary cache for the copy-settings gesture (REQ-BLD-COPY-CONFIG); held // only while Shift is down and cleared on Shift release. @@ -403,8 +384,6 @@ private: std::vector m_copyConfigFlashes; static constexpr qint64 kCopyFlashDurationMs = 300; - bool m_deconstructMode; - std::optional m_deconstructHoverBuildingId; bool m_debugDraw; // Owns the selection across all three categories and the rules for moving