From 56c691abfe7e1e26a28fe1eba62dca2d73ef4187 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 21 Jul 2026 16:19:59 +0200 Subject: [PATCH] Snap belt-drag end tile to a building's input edge When a belt drag ends with the cursor over a non-belt building or construction site, the end tile now snaps to the input-capable adjacent tile closest to the cursor and points into the target, instead of showing an invalid ghost on the occupied tile. Adds BuildingSystem::getInputPorts(id) (reusing computeInputPorts, refactored into a bodyCells+outputPorts core) as the single source of truth for a target's input-capable adjacent tiles; works for both operational buildings (stored inputPorts) and construction sites (mask-derived). GameWorldView tracks the cursor world position and, in recomputeBeltDragPath, picks the nearest input port and overrides the end tile's rotation. Ghost preview, resolve, and apply consume the snapped path unchanged. Implements REQ-BLD-BELT-DRAG snapping-to-a-building. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc --- src/lib/sim/BuildingSystem.cpp | 37 +++++++++++- src/lib/sim/BuildingSystem.h | 9 +++ src/test/BuildingTest.cpp | 105 +++++++++++++++++++++++++++++++++ src/ui/GameWorldView.cpp | 58 +++++++++++++++++- src/ui/GameWorldView.h | 3 + 5 files changed, 208 insertions(+), 4 deletions(-) diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index 0d9a4d3..bd9d3c6 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -267,16 +267,23 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const } std::vector BuildingSystem::computeInputPorts(const Building& b) const +{ + return computeInputPorts(b.bodyCells, b.outputPorts); +} + +std::vector BuildingSystem::computeInputPorts( + const std::vector& bodyCells, + const std::vector& outputPorts) const { // Build lookup sets for quick membership checks. std::set> bodySet; - for (const QPoint& cell : b.bodyCells) + for (const QPoint& cell : bodyCells) { bodySet.insert({cell.x(), cell.y()}); } std::set> outputPortTiles; - for (const Port& port : b.outputPorts) + for (const Port& port : outputPorts) { outputPortTiles.insert({port.tile.x(), port.tile.y()}); } @@ -294,7 +301,7 @@ std::vector BuildingSystem::computeInputPorts(const Building& b) const std::set> seen; std::vector inputPorts; - for (const QPoint& cell : b.bodyCells) + for (const QPoint& cell : bodyCells) { for (int i = 0; i < 4; ++i) { @@ -317,6 +324,30 @@ std::vector BuildingSystem::computeInputPorts(const Building& b) const return inputPorts; } +std::vector BuildingSystem::getInputPorts(BuildingId id) const +{ + if (const Building* building = findBuilding(id)) + { + return building->inputPorts; + } + if (const ConstructionSite* site = findSite(id)) + { + // A site stores no ports; derive its output ports from the mask (absolute) + // and run the same input-edge scan (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS). + const BuildingDef* def = findBuildingDef(site->type); + if (def == nullptr) { return {}; } + const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, site->rotation); + std::vector outputPortsAbsolute; + outputPortsAbsolute.reserve(mask.outputPorts.size()); + for (const Port& port : mask.outputPorts) + { + outputPortsAbsolute.push_back(Port{ site->anchor + port.tile, port.direction }); + } + return computeInputPorts(site->bodyCells, outputPortsAbsolute); + } + return {}; +} + std::vector BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe) { std::vector eligible; diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 64d31af..b27946a 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -171,6 +171,12 @@ public: // Find nearest operational building of the given type; nullptr if none. const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const; + // Input-capable adjacent tiles for a building or construction site + // (REQ-BLD-BELT-DRAG, REQ-MAT-INPUT-PORTS): each returned Port.tile is the + // outside adjacent tile and Port.direction is the belt facing that points into + // the target. Output-port edges are excluded. Empty for an unknown id. + std::vector getInputPorts(BuildingId id) const; + // Register / unregister tile occupancy for ECS station entities. void registerTileOccupancy(const std::vector& cells, BuildingId ownerPlaceholder); void unregisterTileOccupancy(const std::vector& cells); @@ -246,6 +252,9 @@ private: void initShipyardBuffers(Building& b) const; void initSalvageBayBuffer(Building& b) const; std::vector computeInputPorts(const Building& b) const; + // Core input-edge scan shared by operational buildings and construction sites. + std::vector computeInputPorts(const std::vector& bodyCells, + const std::vector& outputPorts) const; std::vector rollReprocessingOutput(const RecipeDef& recipe); bool bodyCellsWithinWorldBounds( const std::vector& bodyCells, diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 4004f58..559b9ff 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include #include @@ -1421,3 +1423,106 @@ TEST_CASE("BuildingSystem: getProductionStatus classifies production state", "[b REQUIRE(statusOf(bay) == ProductionStatus::Producing); // holding scrap -> green } } + +// --------------------------------------------------------------------------- +// getInputPorts (REQ-BLD-BELT-DRAG snapping, REQ-MAT-INPUT-PORTS) +// --------------------------------------------------------------------------- + +namespace +{ + QPoint directionDelta(Rotation direction) + { + switch (direction) + { + case Rotation::North: return QPoint(0, -1); + case Rotation::East: return QPoint(1, 0); + case Rotation::South: return QPoint(0, 1); + case Rotation::West: return QPoint(-1, 0); + } + return QPoint(0, 0); + } + + bool hasInputPort(const std::vector& ports, QPoint tile, Rotation direction) + { + for (const Port& port : ports) + { + if (port.tile == tile && port.direction == direction) { return true; } + } + return false; + } + + // Advances the sim until the given site becomes an operational building, or a + // safety cap is reached. + void buildToCompletion(BuildingSystem& bs, BeltSystem& belts, BuildingId id, + Tick& tick) + { + for (int i = 0; i < 20000 && bs.findBuilding(id) == nullptr; ++i) + { + runTicks(bs, belts, 1, tick); + } + } +} + +TEST_CASE("BuildingSystem: getInputPorts on a miner site lists every input edge", "[building]") +{ + PlacementFixture f; + // Miner mask ["AA","A>"] East → body (0,0),(1,0),(0,1); output tile (1,1) East. + const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); + + const std::vector ports = f.bs.getInputPorts(id); + + // Every perimeter edge except the output-port edge at (1,1), each pointing in. + REQUIRE(ports.size() == 6); + REQUIRE(hasInputPort(ports, QPoint(-1, 0), Rotation::East)); + REQUIRE(hasInputPort(ports, QPoint(0, -1), Rotation::South)); + REQUIRE(hasInputPort(ports, QPoint(2, 0), Rotation::West)); + REQUIRE(hasInputPort(ports, QPoint(1, -1), Rotation::South)); + REQUIRE(hasInputPort(ports, QPoint(-1, 1), Rotation::East)); + REQUIRE(hasInputPort(ports, QPoint(0, 2), Rotation::North)); + + // The output-port tile is never an input port. + REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::North)); + REQUIRE_FALSE(hasInputPort(ports, QPoint(1, 1), Rotation::West)); +} + +TEST_CASE("BuildingSystem: getInputPorts matches between a site and the built building", + "[building]") +{ + PlacementFixture f; + Tick tick = 0; + const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::East, 0).value(); + + const std::vector sitePorts = f.bs.getInputPorts(id); + buildToCompletion(f.bs, f.belts, id, tick); + REQUIRE(f.bs.findBuilding(id) != nullptr); + const std::vector builtPorts = f.bs.getInputPorts(id); + + // The operational path (stored inputPorts) agrees with the site path (mask-derived). + REQUIRE(builtPorts.size() == sitePorts.size()); + for (const Port& port : sitePorts) + { + REQUIRE(hasInputPort(builtPorts, port.tile, port.direction)); + } +} + +TEST_CASE("BuildingSystem: getInputPorts invariants hold for a rotated site", "[building]") +{ + PlacementFixture f; + const BuildingId id = f.bs.place(BuildingType::Miner, QPoint(0, 0), Rotation::South, 0).value(); + const ConstructionSite* site = f.bs.findSite(id); + REQUIRE(site != nullptr); + + std::set> bodySet; + for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); } + + const std::vector ports = f.bs.getInputPorts(id); + REQUIRE_FALSE(ports.empty()); + for (const Port& port : ports) + { + // Each port tile is outside the footprint... + REQUIRE(bodySet.count({port.tile.x(), port.tile.y()}) == 0); + // ...and its direction points into an adjacent body cell. + const QPoint into = port.tile + directionDelta(port.direction); + REQUIRE(bodySet.count({into.x(), into.y()}) == 1); + } +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index ffe9dbb..4e3638c 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -937,7 +937,61 @@ void GameWorldView::placeAtTile(QPoint tile) void GameWorldView::recomputeBeltDragPath(QPoint cursorTile) { - m_beltDragPath = computeBeltDragPath(m_beltDragAnchor, cursorTile, m_ghostRotation); + QPoint endTile = cursorTile; + std::optional forcedEndRotation; + + // If the cursor is over a non-belt building or construction site, snap the end + // tile to the input-capable adjacent tile closest to the cursor, pointing into + // the target (REQ-BLD-BELT-DRAG). + std::optional targetId = buildingAtTile(cursorTile); + std::optional targetType; + if (targetId.has_value()) + { + if (const Building* building = m_sim->getBuildings().findBuilding(*targetId)) + { + targetType = building->type; + } + } + else if (std::optional siteId = siteAtTile(cursorTile); siteId.has_value()) + { + targetId = siteId; + if (const ConstructionSite* site = m_sim->getBuildings().findSite(*siteId)) + { + targetType = site->type; + } + } + + if (targetId.has_value() && targetType.has_value() + && *targetType != BuildingType::Belt) + { + const std::vector inputPorts = m_sim->getBuildings().getInputPorts(*targetId); + std::optional best; + float bestDistanceSq = 0.0f; + for (const Port& port : inputPorts) + { + const QVector2D center(static_cast(port.tile.x()) + 0.5f, + static_cast(port.tile.y()) + 0.5f); + const float distanceSq = (center - m_cursorWorldPos).lengthSquared(); + if (!best.has_value() || distanceSq < bestDistanceSq) + { + best = port; + bestDistanceSq = distanceSq; + } + } + if (best.has_value()) + { + endTile = best->tile; + forcedEndRotation = best->direction; + } + } + + m_beltDragPath = computeBeltDragPath(m_beltDragAnchor, endTile, m_ghostRotation); + if (forcedEndRotation.has_value() && !m_beltDragPath.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; + } } std::vector GameWorldView::resolveBeltDragPath() const @@ -2187,6 +2241,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) // is placed until release (REQ-BLD-BELT-DRAG). m_dragging = true; m_beltDragAnchor = tile; + m_cursorWorldPos = widgetToWorld(event->pos()); recomputeBeltDragPath(tile); } else @@ -2350,6 +2405,7 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) void GameWorldView::mouseMoveEvent(QMouseEvent* event) { const QPoint tile = widgetToTile(event->pos()); + m_cursorWorldPos = widgetToWorld(event->pos()); if (m_builderType.has_value()) { diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 37ae794..3c1d8a8 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -279,6 +279,9 @@ private: // on release. Empty unless a belt drag is in progress. std::vector m_beltDragPath; QPoint m_beltDragAnchor; + // Last known cursor position in world (tile) units; used to pick the belt-drag + // end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG). + QVector2D m_cursorWorldPos; bool m_dragging; std::optional m_blueprintMode;