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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
This commit is contained in:
2026-07-21 16:19:59 +02:00
parent 77519826b2
commit 56c691abfe
5 changed files with 208 additions and 4 deletions

View File

@@ -267,16 +267,23 @@ void BuildingSystem::initSalvageBayBuffer(Building& b) const
}
std::vector<Port> BuildingSystem::computeInputPorts(const Building& b) const
{
return computeInputPorts(b.bodyCells, b.outputPorts);
}
std::vector<Port> BuildingSystem::computeInputPorts(
const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts) const
{
// Build lookup sets for quick membership checks.
std::set<std::pair<int, int>> bodySet;
for (const QPoint& cell : b.bodyCells)
for (const QPoint& cell : bodyCells)
{
bodySet.insert({cell.x(), cell.y()});
}
std::set<std::pair<int, int>> 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<Port> BuildingSystem::computeInputPorts(const Building& b) const
std::set<std::pair<int, int>> seen;
std::vector<Port> 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<Port> BuildingSystem::computeInputPorts(const Building& b) const
return inputPorts;
}
std::vector<Port> 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<Port> 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<Item> BuildingSystem::rollReprocessingOutput(const RecipeDef& recipe)
{
std::vector<const RecipeOutput*> eligible;

View File

@@ -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<Port> getInputPorts(BuildingId id) const;
// Register / unregister tile occupancy for ECS station entities.
void registerTileOccupancy(const std::vector<QPoint>& cells, BuildingId ownerPlaceholder);
void unregisterTileOccupancy(const std::vector<QPoint>& cells);
@@ -246,6 +252,9 @@ private:
void initShipyardBuffers(Building& b) const;
void initSalvageBayBuffer(Building& b) const;
std::vector<Port> computeInputPorts(const Building& b) const;
// Core input-edge scan shared by operational buildings and construction sites.
std::vector<Port> computeInputPorts(const std::vector<QPoint>& bodyCells,
const std::vector<Port>& outputPorts) const;
std::vector<Item> rollReprocessingOutput(const RecipeDef& recipe);
bool bodyCellsWithinWorldBounds(
const std::vector<QPoint>& bodyCells,

View File

@@ -2,7 +2,9 @@
#include <map>
#include <random>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include <QPoint>
@@ -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<Port>& 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<Port> 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<Port> sitePorts = f.bs.getInputPorts(id);
buildToCompletion(f.bs, f.belts, id, tick);
REQUIRE(f.bs.findBuilding(id) != nullptr);
const std::vector<Port> 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<std::pair<int, int>> bodySet;
for (const QPoint& cell : site->bodyCells) { bodySet.insert({cell.x(), cell.y()}); }
const std::vector<Port> 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);
}
}

View File

@@ -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<Rotation> 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<BuildingId> targetId = buildingAtTile(cursorTile);
std::optional<BuildingType> targetType;
if (targetId.has_value())
{
if (const Building* building = m_sim->getBuildings().findBuilding(*targetId))
{
targetType = building->type;
}
}
else if (std::optional<BuildingId> 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<Port> inputPorts = m_sim->getBuildings().getInputPorts(*targetId);
std::optional<Port> best;
float bestDistanceSq = 0.0f;
for (const Port& port : inputPorts)
{
const QVector2D center(static_cast<float>(port.tile.x()) + 0.5f,
static_cast<float>(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::BeltDragResolved> 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())
{

View File

@@ -279,6 +279,9 @@ private:
// 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;
std::optional<Blueprint> m_blueprintMode;