From b2c1ea34fdb426c8e86ea436eedaea4e99e2b8ef Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Tue, 21 Jul 2026 21:05:31 +0200 Subject: [PATCH] Implement deferred L-shaped belt drag placement --- docs/requirements.md | 9 +- src/lib/core/BeltDragPath.cpp | 94 ++++++++++++++++++++ src/lib/core/BeltDragPath.h | 27 ++++++ src/lib/core/CMakeLists.txt | 2 + src/test/BeltDragPathTest.cpp | 157 +++++++++++++++++++++++++++++++++ src/test/CMakeLists.txt | 1 + src/ui/GameWorldView.cpp | 160 ++++++++++++++++++++++++++++------ src/ui/GameWorldView.h | 25 +++++- 8 files changed, 444 insertions(+), 31 deletions(-) create mode 100644 src/lib/core/BeltDragPath.cpp create mode 100644 src/lib/core/BeltDragPath.h create mode 100644 src/test/BeltDragPathTest.cpp diff --git a/docs/requirements.md b/docs/requirements.md index c859a96..c327d31 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -100,13 +100,16 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des - REQ-BLD-COST: The player places buildings from a build menu. Placement costs building blocks from the global stock. The cost per building type is read from `buildings.toml [[building]].cost`. - REQ-BLD-QUEUE: Placed buildings enter a construction queue and are built one at a time. Each building takes a duration defined in `buildings.toml [[building]].construction_time_seconds` to construct. - REQ-BLD-ASTEROID-ONLY: Buildings can only be placed on asteroid tiles (per surface_mask; tiles marked `S` may extend into space). -- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again. +- REQ-BLD-BUILDER-MODE: Clicking a build button activates builder mode for that building type. Builder mode is exited by right-clicking in the game world or clicking the same build button again. (Exception: while a belt drag placement is in progress, right-clicking cancels that drag instead of exiting, and builder mode stays active — REQ-BLD-BELT-DRAG.) - REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring. - REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port. -- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. +- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.) - REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions. - REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost). -- REQ-BLD-BELT-DRAG: For belts, the player can click and drag across multiple tiles to place a construction site on each tile in one gesture. +- REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile. + - **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step, and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**. + - **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts. + - **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE). - REQ-BLD-TUNNEL-AUTO-SWITCH: After the player successfully places a Tunnel Entry construction site, builder mode automatically switches to Tunnel Exit (and vice versa), preserving the current ghost rotation. This makes it easy to immediately place the paired end without manually selecting the complementary type. - REQ-BLD-DEMOLISH: The player can demolish a placed factory building. Demolition returns `world.toml [world].refund_percentage` percent of the original building block cost (default 75%) to the global stock. Exception: if the building is still in the construction queue (not yet fully built, including the one currently being constructed), it is removed from the queue and the **full** building block cost is refunded. The HQ and player defence stations cannot be demolished. - REQ-BLD-DEMOLISH-CLICK: While in demolish mode (REQ-UI-HOTKEYS, REQ-UI-DEMOLISH-BUTTON), left-clicking a placed factory building or construction site in the game world demolishes it, following the refund rules of REQ-BLD-DEMOLISH — the partial refund for built buildings and the full refund for still-queued construction sites. Clicking a building that cannot be demolished (the HQ or a player defence station, per REQ-BLD-DEMOLISH), or clicking empty world space, has no effect. Demolish mode stays active after a demolition so the player can demolish further buildings without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Demolish button (REQ-UI-DEMOLISH-BUTTON). diff --git a/src/lib/core/BeltDragPath.cpp b/src/lib/core/BeltDragPath.cpp new file mode 100644 index 0000000..fd0ec38 --- /dev/null +++ b/src/lib/core/BeltDragPath.cpp @@ -0,0 +1,94 @@ +#include "BeltDragPath.h" + +#include + +namespace +{ + int signOf(int value) + { + if (value > 0) { return 1; } + if (value < 0) { return -1; } + return 0; + } + + // Direction stepping from one tile to an orthogonally adjacent tile. + Rotation directionBetween(QPoint from, QPoint to) + { + const QPoint delta = to - from; + if (delta.x() > 0) { return Rotation::East; } + if (delta.x() < 0) { return Rotation::West; } + if (delta.y() > 0) { return Rotation::South; } + return Rotation::North; + } +} + +std::vector computeBeltDragPath(QPoint anchor, QPoint cursor, + Rotation orientation) +{ + const bool horizontalFirst = + (orientation == Rotation::East || orientation == Rotation::West); + + // Build the ordered tile coordinates: first leg along the primary axis to the + // corner, then the orthogonal leg to the cursor (no duplicated corner tile). + std::vector coords; + if (horizontalFirst) + { + const int stepX = signOf(cursor.x() - anchor.x()); + for (int x = anchor.x(); ; x += stepX) + { + coords.push_back(QPoint(x, anchor.y())); + if (x == cursor.x() || stepX == 0) { break; } + } + const int stepY = signOf(cursor.y() - anchor.y()); + if (stepY != 0) + { + for (int y = anchor.y() + stepY; ; y += stepY) + { + coords.push_back(QPoint(cursor.x(), y)); + if (y == cursor.y()) { break; } + } + } + } + else + { + const int stepY = signOf(cursor.y() - anchor.y()); + for (int y = anchor.y(); ; y += stepY) + { + coords.push_back(QPoint(anchor.x(), y)); + if (y == cursor.y() || stepY == 0) { break; } + } + const int stepX = signOf(cursor.x() - anchor.x()); + if (stepX != 0) + { + for (int x = anchor.x() + stepX; ; x += stepX) + { + coords.push_back(QPoint(x, cursor.y())); + if (x == cursor.x()) { break; } + } + } + } + + // Assign each tile the direction toward the next tile; the last tile keeps its + // incoming step direction, and a single-tile path keeps the belt orientation. + std::vector path; + path.reserve(coords.size()); + const std::size_t count = coords.size(); + for (std::size_t index = 0; index < count; ++index) + { + Rotation rotation; + if (count == 1) + { + rotation = orientation; + } + else if (index + 1 < count) + { + rotation = directionBetween(coords[index], coords[index + 1]); + } + else + { + rotation = directionBetween(coords[index - 1], coords[index]); + } + path.push_back(BeltPathTile{ coords[index], rotation }); + } + return path; +} diff --git a/src/lib/core/BeltDragPath.h b/src/lib/core/BeltDragPath.h new file mode 100644 index 0000000..9e214bc --- /dev/null +++ b/src/lib/core/BeltDragPath.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include + +#include "Rotation.h" + +// One tile of a belt drag-placement path: the tile coordinate and the belt +// orientation it should be given (REQ-BLD-BELT-DRAG). +struct BeltPathTile +{ + QPoint tile; + Rotation rotation; +}; + +// Computes the rectilinear (L-shaped) belt path from `anchor` to `cursor` for a +// belt whose current orientation is `orientation` (REQ-BLD-BELT-DRAG). The path +// first runs along the axis parallel to `orientation` (horizontal for East/West, +// vertical for North/South), stepping toward the cursor's coordinate on that axis +// to the corner tile, then runs along the orthogonal axis to the cursor tile. Each +// tile is oriented to point toward the next tile along the path; the final tile +// keeps the direction of its incoming step, and a single-tile path keeps +// `orientation`. Returned tiles are ordered from anchor to cursor with no duplicate +// corner tile. +std::vector computeBeltDragPath(QPoint anchor, QPoint cursor, + Rotation orientation); diff --git a/src/lib/core/CMakeLists.txt b/src/lib/core/CMakeLists.txt index 5bfb1df..c332289 100644 --- a/src/lib/core/CMakeLists.txt +++ b/src/lib/core/CMakeLists.txt @@ -11,6 +11,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/Port.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceOption.h ${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h + ${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h PARENT_SCOPE ) @@ -19,6 +20,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/BuildingType.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityAdmin.cpp ${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp PARENT_SCOPE ) diff --git a/src/test/BeltDragPathTest.cpp b/src/test/BeltDragPathTest.cpp new file mode 100644 index 0000000..40e4b97 --- /dev/null +++ b/src/test/BeltDragPathTest.cpp @@ -0,0 +1,157 @@ +#include "catch.hpp" + +#include + +#include + +#include "BeltDragPath.h" +#include "Rotation.h" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static std::vector tilesOf(const std::vector& path) +{ + std::vector tiles; + for (const BeltPathTile& entry : path) { tiles.push_back(entry.tile); } + return tiles; +} + +static std::vector rotationsOf(const std::vector& path) +{ + std::vector rotations; + for (const BeltPathTile& entry : path) { rotations.push_back(entry.rotation); } + return rotations; +} + +// --------------------------------------------------------------------------- +// Single tile +// --------------------------------------------------------------------------- + +TEST_CASE("Single-tile path keeps the belt orientation") +{ + for (Rotation orientation : { Rotation::North, Rotation::East, + Rotation::South, Rotation::West }) + { + const std::vector path = + computeBeltDragPath(QPoint(3, 4), QPoint(3, 4), orientation); + REQUIRE(path.size() == 1); + REQUIRE(path[0].tile == QPoint(3, 4)); + REQUIRE(path[0].rotation == orientation); + } +} + +// --------------------------------------------------------------------------- +// Straight runs +// --------------------------------------------------------------------------- + +TEST_CASE("Straight horizontal run faces along the row toward the cursor") +{ + const std::vector path = + computeBeltDragPath(QPoint(0, 0), QPoint(3, 0), Rotation::East); + REQUIRE(tilesOf(path) == std::vector{ + QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(3, 0) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::East, Rotation::East, Rotation::East, Rotation::East }); +} + +TEST_CASE("Straight horizontal run toward the left faces West") +{ + const std::vector path = + computeBeltDragPath(QPoint(0, 0), QPoint(-2, 0), Rotation::East); + REQUIRE(tilesOf(path) == std::vector{ + QPoint(0, 0), QPoint(-1, 0), QPoint(-2, 0) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::West, Rotation::West, Rotation::West }); +} + +TEST_CASE("Straight vertical run faces along the column toward the cursor") +{ + const std::vector path = + computeBeltDragPath(QPoint(0, 0), QPoint(0, 3), Rotation::South); + REQUIRE(tilesOf(path) == std::vector{ + QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(0, 3) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::South, Rotation::South, Rotation::South, Rotation::South }); +} + +// A vertical target with a horizontal orientation still yields a straight vertical +// line (the parallel-axis leg is zero-length). +TEST_CASE("Vertical target with horizontal orientation is a straight vertical line") +{ + const std::vector path = + computeBeltDragPath(QPoint(2, 0), QPoint(2, 2), Rotation::East); + REQUIRE(tilesOf(path) == std::vector{ + QPoint(2, 0), QPoint(2, 1), QPoint(2, 2) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::South, Rotation::South, Rotation::South }); +} + +// --------------------------------------------------------------------------- +// L-shaped paths — horizontal-first (East/West orientation) +// --------------------------------------------------------------------------- + +TEST_CASE("East orientation goes horizontal then vertical (down-right)") +{ + const std::vector path = + computeBeltDragPath(QPoint(0, 0), QPoint(2, 2), Rotation::East); + // Leg 1 East to the corner (2,0), then Leg 2 South to the cursor (2,2). + REQUIRE(tilesOf(path) == std::vector{ + QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(2, 1), QPoint(2, 2) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::East, Rotation::East, Rotation::South, Rotation::South, + Rotation::South }); +} + +TEST_CASE("East orientation with cursor up-left goes horizontal (West) then vertical (North)") +{ + const std::vector path = + computeBeltDragPath(QPoint(0, 0), QPoint(-2, -2), Rotation::East); + REQUIRE(tilesOf(path) == std::vector{ + QPoint(0, 0), QPoint(-1, 0), QPoint(-2, 0), QPoint(-2, -1), + QPoint(-2, -2) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::West, Rotation::West, Rotation::North, Rotation::North, + Rotation::North }); +} + +TEST_CASE("West orientation is horizontal-first as well (up-right cursor)") +{ + const std::vector path = + computeBeltDragPath(QPoint(0, 0), QPoint(2, -2), Rotation::West); + // Horizontal axis first: East toward the cursor to the corner (2,0), then North. + REQUIRE(tilesOf(path) == std::vector{ + QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(2, -1), QPoint(2, -2) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::East, Rotation::East, Rotation::North, Rotation::North, + Rotation::North }); +} + +// --------------------------------------------------------------------------- +// L-shaped paths — vertical-first (North/South orientation) +// --------------------------------------------------------------------------- + +TEST_CASE("South orientation goes vertical then horizontal (down-right)") +{ + const std::vector path = + computeBeltDragPath(QPoint(0, 0), QPoint(2, 2), Rotation::South); + // Leg 1 South to the corner (0,2), then Leg 2 East to the cursor (2,2). + REQUIRE(tilesOf(path) == std::vector{ + QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(1, 2), QPoint(2, 2) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::South, Rotation::South, Rotation::East, Rotation::East, + Rotation::East }); +} + +TEST_CASE("North orientation is vertical-first (down-left cursor)") +{ + const std::vector path = + computeBeltDragPath(QPoint(0, 0), QPoint(-2, 2), Rotation::North); + // Vertical axis first: South toward the cursor to the corner (0,2), then West. + REQUIRE(tilesOf(path) == std::vector{ + QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(-1, 2), QPoint(-2, 2) }); + REQUIRE(rotationsOf(path) == std::vector{ + Rotation::South, Rotation::South, Rotation::West, Rotation::West, + Rotation::West }); +} diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index dd0a975..7ad0eb9 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -8,6 +8,7 @@ add_files( SimulationTest.cpp BeltSystemTest.cpp SurfaceMaskTest.cpp + BeltDragPathTest.cpp BuildingTest.cpp BuildingConfigTest.cpp ShipTest.cpp diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index e665d4e..9a4993c 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -903,23 +903,12 @@ void GameWorldView::placeAtTile(QPoint tile) return; } - // For placements whose UI follow-up depends on success (belt-drag bookkeeping, - // tunnel entry/exit toggle), pre-validate occupancy + affordability so the - // optimistic UI update matches what the deferred command will do — isValidPlacement - // (above) already covered terrain/bounds. - if (type == BuildingType::Belt) - { - if (m_beltDragTiles.count(tile) > 0) - { - return; - } - if (!m_sim->getBuildings().isTileOccupied(tile) && canAfford(type)) - { - enqueuePlaceBuilding(type, tile, m_ghostRotation); - m_beltDragTiles.insert(tile); - } - } - else if (type == BuildingType::Splitter + // For placements whose UI follow-up depends on success (the tunnel entry/exit + // toggle), pre-validate occupancy + affordability so the optimistic UI update + // matches what the deferred command will do — isValidPlacement (above) already + // covered terrain/bounds. Belts are placed via the drag path (applyBeltDragPath), + // not here. + if (type == BuildingType::Splitter || type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) { @@ -942,6 +931,80 @@ void GameWorldView::placeAtTile(QPoint tile) } } +// --------------------------------------------------------------------------- +// Belt drag placement (REQ-BLD-BELT-DRAG) +// --------------------------------------------------------------------------- + +void GameWorldView::recomputeBeltDragPath(QPoint cursorTile) +{ + m_beltDragPath = computeBeltDragPath(m_beltDragAnchor, cursorTile, m_ghostRotation); +} + +std::vector GameWorldView::resolveBeltDragPath() const +{ + std::vector resolved; + resolved.reserve(m_beltDragPath.size()); + + const BuildingDef* def = 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) + { + BeltDragResolved item; + const std::optional rotateTarget = + m_sim->getBuildings().findRotateInPlaceTarget( + BuildingType::Belt, entry.tile, entry.rotation); + if (rotateTarget.has_value()) + { + // A tile holding only a belt (or belt site) is re-oriented, no cost. + item.action = BeltTileAction::RotateInPlace; + item.affordable = true; + item.rotateId = rotateTarget; + } + else if (isValidPlacement(BuildingType::Belt, entry.tile, entry.rotation)) + { + // Empty, valid cell: a new belt, subject to cumulative affordability. + item.action = BeltTileAction::PlaceNew; + item.affordable = (spent + beltCost <= stock); + item.rotateId = std::nullopt; + if (item.affordable) { spent += beltCost; } + } + else + { + // Occupied by a non-belt building/site, or otherwise invalid terrain. + item.action = BeltTileAction::Invalid; + item.affordable = false; + item.rotateId = std::nullopt; + } + resolved.push_back(item); + } + return resolved; +} + +void GameWorldView::applyBeltDragPath() +{ + const std::vector resolved = resolveBeltDragPath(); + for (std::size_t index = 0; index < resolved.size(); ++index) + { + const BeltDragResolved& item = resolved[index]; + const BeltPathTile& entry = m_beltDragPath[index]; + if (item.action == BeltTileAction::PlaceNew && item.affordable) + { + enqueuePlaceBuilding(BuildingType::Belt, entry.tile, entry.rotation); + } + else if (item.action == BeltTileAction::RotateInPlace) + { + std::shared_ptr command = + std::make_shared(); + command->id = *item.rotateId; + command->newRotation = entry.rotation; + enqueueCommand(command); + } + } +} + // --------------------------------------------------------------------------- // Port glyph helper // --------------------------------------------------------------------------- @@ -1659,9 +1722,32 @@ void GameWorldView::drawOverlays(QPainter& painter) // Builder-mode ghost if (m_builderType.has_value()) { - drawBuildingGhost(painter, *m_builderType, m_ghostTile, - m_ghostRotation, m_ghostValid, - /*showPortTargetGlyphs*/ true); + if (*m_builderType == BuildingType::Belt && m_dragging) + { + // 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 + // use the invalid color; unaffordable tiles show no ghost at all. + const std::vector resolved = resolveBeltDragPath(); + for (std::size_t index = 0; index < resolved.size(); ++index) + { + const BeltDragResolved& item = resolved[index]; + if (item.action == BeltTileAction::PlaceNew && !item.affordable) + { + continue; + } + const BeltPathTile& entry = m_beltDragPath[index]; + drawBuildingGhost(painter, BuildingType::Belt, entry.tile, + entry.rotation, + /*valid*/ item.action != BeltTileAction::Invalid, + /*showPortTargetGlyphs*/ true); + } + } + else + { + drawBuildingGhost(painter, *m_builderType, m_ghostTile, + m_ghostRotation, m_ghostValid, + /*showPortTargetGlyphs*/ true); + } } // Blueprint placement ghost @@ -2061,7 +2147,20 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) { if (event->button() == Qt::RightButton) { - if (m_builderType.has_value()) { exitBuilderMode(); } + if (m_builderType.has_value()) + { + 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(); + } + } else if (m_blueprintMode.has_value()) { exitBlueprintMode(); } else if (m_demolishMode) { toggleDemolishMode(); } else if (event->modifiers() & Qt::ShiftModifier) @@ -2084,9 +2183,11 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) const BuildingType type = *m_builderType; if (type == BuildingType::Belt) { - m_dragging = true; - m_beltDragTiles.clear(); - placeAtTile(tile); + // 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; + recomputeBeltDragPath(tile); } else { @@ -2257,7 +2358,9 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event) if (m_dragging) { - placeAtTile(tile); + // Belt drag: update the previewed path; placement happens on release + // (REQ-BLD-BELT-DRAG). + recomputeBeltDragPath(tile); } } else if (m_blueprintMode.has_value()) @@ -2281,8 +2384,11 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event) if (m_dragging) { + // Apply the previewed belt path now that the button is released + // (REQ-BLD-BELT-DRAG). + applyBeltDragPath(); m_dragging = false; - m_beltDragTiles.clear(); + m_beltDragPath.clear(); } if (m_boxSelecting) @@ -2565,7 +2671,7 @@ void GameWorldView::exitBlueprintMode() void GameWorldView::exitBuilderMode() { m_builderType.reset(); - m_beltDragTiles.clear(); + m_beltDragPath.clear(); m_dragging = false; EventManager::getInstance()->sendEventImmediately( std::make_shared()); diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 6d612db..37ae794 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -35,6 +35,7 @@ #include "SpeedChangeRequestedEvent.h" #include "entt/entity/entity.hpp" +#include "BeltDragPath.h" #include "CommandManager.h" #include "EntitySelectionChangedEvent.h" #include "GameConfig.h" @@ -204,6 +205,24 @@ private: void stepSpeed(int delta); void placeAtTile(QPoint tile); + // Belt drag placement (REQ-BLD-BELT-DRAG). + // Per-path-tile decision, shared by ghost drawing and release-time placement. + enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid }; + struct BeltDragResolved + { + BeltTileAction action; + 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. + void recomputeBeltDragPath(QPoint cursorTile); + // Classifies each path tile against the current sim state, applying cumulative + // affordability to the PlaceNew tiles. + std::vector resolveBeltDragPath() const; + // Enqueues placements and rotate-in-place commands for the resolved path. + void applyBeltDragPath(); + // Copy-settings gesture (REQ-BLD-COPY-CONFIG): Shift+right-click copies a // building's configuration into m_copiedConfig; Shift+left-click applies it to // another building of the same type via the existing configuration commands. @@ -255,7 +274,11 @@ private: Rotation m_ghostRotation; QPoint m_ghostTile; bool m_ghostValid; - std::set m_beltDragTiles; + // 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; bool m_dragging; std::optional m_blueprintMode;