From 119d13ba1f5c193feb92ada540c9f4a7fcafba5c Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Wed, 5 Aug 2026 17:20:22 +0200 Subject: [PATCH] extract BuildModeController, fixing a silent blueprint exit Builder, blueprint and deconstruct were three independent flags, and every entry point cleared the other two by hand. The copies had drifted, and one had a real bug: enterBuilderMode reset the blueprint directly instead of calling exitBlueprintMode, so BlueprintModeExitedEvent never fired and BlueprintPanel never ran clearActiveBlueprintButton. Activating a blueprint and then picking a building left the blueprint button highlighted for a mode that had ended. Exclusivity is now structural. One mode is active, and every transition runs through enterMode(), which exits whatever was active first. Which mode the player switches to can no longer change what the mode they left announces - a test covers all four crossings, plus the blueprint regression above. The controller also owns the state that belongs to a mode and had to be cleared with it: ghost tile/rotation/validity, the resolved tunnel end and its completion partner, the belt drag, and the deconstruct hover. Those were the things the hand-written resets kept forgetting. Everything needing the simulation stays in GameWorldView - placement validity, tunnel matching, belt path building - and is handed back through setGhostValidity, setTunnelGhost and setBeltDragPath. That is what keeps the controller a plain value with tests. Two smaller behaviour changes, both dropping redundant events. Restart now announces only the mode that was actually active rather than all three exits unconditionally; and entering builder or blueprint mode no longer publishes DeconstructModeChangedEvent(false) when deconstruct mode was not on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K --- src/lib/core/BuildModeController.cpp | 261 +++++++++++++++++++++++ src/lib/core/BuildModeController.h | 123 +++++++++++ src/lib/core/CMakeLists.txt | 2 + src/test/BuildModeControllerTest.cpp | 301 +++++++++++++++++++++++++++ src/test/CMakeLists.txt | 1 + src/ui/GameWorldView.cpp | 264 +++++++++-------------- src/ui/GameWorldView.h | 55 ++--- 7 files changed, 803 insertions(+), 204 deletions(-) create mode 100644 src/lib/core/BuildModeController.cpp create mode 100644 src/lib/core/BuildModeController.h create mode 100644 src/test/BuildModeControllerTest.cpp 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