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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
2026-08-05 17:20:22 +02:00
parent 1eca61e934
commit 119d13ba1f
7 changed files with 803 additions and 204 deletions

View File

@@ -0,0 +1,261 @@
#include "BuildModeController.h"
#include <memory>
#include <utility>
#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<BuilderModeExitedEvent>());
break;
case BuildMode::Blueprint:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>());
break;
case BuildMode::Deconstruct:
m_deconstructHoverBuildingId.reset();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(false));
break;
case BuildMode::None:
break;
}
m_mode = mode;
if (mode == BuildMode::Deconstruct)
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(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<QPoint>& BuildModeController::getTunnelPartnerTile() const
{
return m_tunnelPartnerTile;
}
void BuildModeController::setTunnelGhost(BuildingType resolvedType,
std::optional<QPoint> 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<BeltPathTile>& BuildModeController::getBeltDragPath() const
{
return m_beltDragPath;
}
void BuildModeController::beginBeltDrag(QPoint anchorTile)
{
m_draggingBelt = true;
m_beltDragAnchor = anchorTile;
}
void BuildModeController::setBeltDragPath(std::vector<BeltPathTile> 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<BuildingId>&
BuildModeController::getDeconstructHoverBuildingId() const
{
return m_deconstructHoverBuildingId;
}
void BuildModeController::setDeconstructHoverBuildingId(std::optional<BuildingId> id)
{
m_deconstructHoverBuildingId = std::move(id);
}

View File

@@ -0,0 +1,123 @@
#pragma once
#include <optional>
#include <vector>
#include <QPoint>
#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<QPoint>& getTunnelPartnerTile() const;
void setTunnelGhost(BuildingType resolvedType, std::optional<QPoint> partnerTile);
// --- belt drag placement (REQ-BLD-BELT-DRAG) ------------------------------
bool isDraggingBelt() const;
QPoint getBeltDragAnchor() const;
const std::vector<BeltPathTile>& getBeltDragPath() const;
void beginBeltDrag(QPoint anchorTile);
void setBeltDragPath(std::vector<BeltPathTile> 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<BuildingId>& getDeconstructHoverBuildingId() const;
void setDeconstructHoverBuildingId(std::optional<BuildingId> 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<QPoint> m_tunnelPartnerTile;
bool m_draggingBelt = false;
QPoint m_beltDragAnchor;
std::vector<BeltPathTile> m_beltDragPath;
Blueprint m_blueprint;
QPoint m_blueprintGhostTile;
std::optional<BuildingId> m_deconstructHoverBuildingId;
};

View File

@@ -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
)

View File

@@ -0,0 +1,301 @@
#include "catch.hpp"
#include <memory>
#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<BuilderModeExitedEvent,
BlueprintModeExitedEvent,
DeconstructModeChangedEvent>
{
public:
ModeEventSpy() { registerForEvents(); }
~ModeEventSpy() { unregisterForEvents(); }
int builderExits = 0;
int blueprintExits = 0;
int deconstructChanges = 0;
bool lastDeconstructActive = false;
private:
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/) override
{
++builderExits;
}
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/) override
{
++blueprintExits;
}
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> 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());
}

View File

@@ -15,6 +15,7 @@ add_files(
WorldCoordinatesTest.cpp
WorldCameraTest.cpp
SelectionControllerTest.cpp
BuildModeControllerTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -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<BuildingId> 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<RotateInPlaceCommand> command =
std::make_shared<RotateInPlaceCommand>();
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<BeltPathTile> 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::BeltDragResolved> GameWorldView::resolveBeltDragPath() const
{
std::vector<BeltDragResolved> 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<BuildingId> 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<QPoint>& 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<BuildingId> 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<DeconstructModeChangedEvent>(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<DeconstructModeChangedEvent>(false));
}
void GameWorldView::enterBlueprintMode(Blueprint blueprint)
{
if (m_builderType.has_value()) { exitBuilderMode(); }
m_deconstructMode = false;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<DeconstructModeChangedEvent>(false));
m_blueprintGhostTile = m_ghostTile;
m_blueprintMode = std::move(blueprint);
}
void GameWorldView::exitBlueprintMode()
{
m_blueprintMode.reset();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>());
}
void GameWorldView::exitBuilderMode()
{
m_builderType.reset();
m_beltDragPath.clear();
m_dragging = false;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BuilderModeExitedEvent>());
}
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<DeconstructModeChangedEvent>(false));
m_selection.clearAll();
m_copiedConfig = std::nullopt;
m_copyConfigFlashes.clear();
@@ -2879,27 +2810,27 @@ void GameWorldView::handleEvent(std::shared_ptr<const BeamFiredEvent> event)
void GameWorldView::handleEvent(std::shared_ptr<const BuildingTypeSelectedEvent> event)
{
enterBuilderMode(event->type);
m_buildMode.enterBuilderMode(event->type);
}
void GameWorldView::handleEvent(std::shared_ptr<const ExitBuilderModeRequestedEvent> /*event*/)
{
exitBuilderMode();
m_buildMode.exitBuilderMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const DeconstructModeToggleRequestedEvent> /*event*/)
{
toggleDeconstructMode();
m_buildMode.toggleDeconstructMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event)
{
enterBlueprintMode(event->blueprint);
m_buildMode.enterBlueprintMode(event->blueprint);
}
void GameWorldView::handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> /*event*/)
{
exitBlueprintMode();
m_buildMode.exitBlueprintMode();
}
void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event)
@@ -2939,9 +2870,10 @@ void GameWorldView::handleEvent(std::shared_ptr<const ModeCancelRequestedEvent>
{
// 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<const DebugDrawToggleRequestedEvent> /*event*/)

View File

@@ -19,6 +19,7 @@
#include <QVector2D>
#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<BuildingId> 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<ActiveBeam> m_activeBeams;
std::optional<BuildingType> 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<QPoint> 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<BeltPathTile> 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<Blueprint> 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<CopyConfigFlash> m_copyConfigFlashes;
static constexpr qint64 kCopyFlashDurationMs = 300;
bool m_deconstructMode;
std::optional<BuildingId> m_deconstructHoverBuildingId;
bool m_debugDraw;
// Owns the selection across all three categories and the rules for moving