From 42b86785a5329d91fc69050d4c423a52a7658714 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 21 Jul 2026 12:59:21 +0200 Subject: [PATCH] Implement deferred L-shaped belt drag placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Belts are no longer placed on hover during a drag. Left-press anchors the drag; as the cursor moves, a rectilinear (L-shaped) path of belt ghosts is previewed from the anchor to the cursor — first leg parallel to the belt's current orientation, then orthogonal — with each tile auto-oriented to follow the path. Construction sites are placed on release: new valid tiles are placed (subject to cumulative affordability), tiles holding only a belt are re-oriented in place, and occupied/invalid tiles are skipped. Unaffordable tiles show no ghost. Right-click during a drag cancels it without leaving belt build mode. Path geometry is factored into a pure computeBeltDragPath() in lib/core with Catch2 coverage; GameWorldView owns the classification, affordability, and rendering against live sim state. Implements REQ-BLD-BELT-DRAG. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc --- 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 +++++- 7 files changed, 438 insertions(+), 28 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/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;