Implement deferred L-shaped belt drag placement

This commit is contained in:
2026-07-21 21:05:31 +02:00
parent 1cbc695bc5
commit b2c1ea34fd
8 changed files with 444 additions and 31 deletions

View File

@@ -0,0 +1,94 @@
#include "BeltDragPath.h"
#include <cstddef>
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<BeltPathTile> 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<QPoint> 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<BeltPathTile> 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;
}

View File

@@ -0,0 +1,27 @@
#pragma once
#include <vector>
#include <QPoint>
#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<BeltPathTile> computeBeltDragPath(QPoint anchor, QPoint cursor,
Rotation orientation);

View File

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

View File

@@ -0,0 +1,157 @@
#include "catch.hpp"
#include <vector>
#include <QPoint>
#include "BeltDragPath.h"
#include "Rotation.h"
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static std::vector<QPoint> tilesOf(const std::vector<BeltPathTile>& path)
{
std::vector<QPoint> tiles;
for (const BeltPathTile& entry : path) { tiles.push_back(entry.tile); }
return tiles;
}
static std::vector<Rotation> rotationsOf(const std::vector<BeltPathTile>& path)
{
std::vector<Rotation> 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<BeltPathTile> 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<BeltPathTile> path =
computeBeltDragPath(QPoint(0, 0), QPoint(3, 0), Rotation::East);
REQUIRE(tilesOf(path) == std::vector<QPoint>{
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(3, 0) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
Rotation::East, Rotation::East, Rotation::East, Rotation::East });
}
TEST_CASE("Straight horizontal run toward the left faces West")
{
const std::vector<BeltPathTile> path =
computeBeltDragPath(QPoint(0, 0), QPoint(-2, 0), Rotation::East);
REQUIRE(tilesOf(path) == std::vector<QPoint>{
QPoint(0, 0), QPoint(-1, 0), QPoint(-2, 0) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
Rotation::West, Rotation::West, Rotation::West });
}
TEST_CASE("Straight vertical run faces along the column toward the cursor")
{
const std::vector<BeltPathTile> path =
computeBeltDragPath(QPoint(0, 0), QPoint(0, 3), Rotation::South);
REQUIRE(tilesOf(path) == std::vector<QPoint>{
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(0, 3) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
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<BeltPathTile> path =
computeBeltDragPath(QPoint(2, 0), QPoint(2, 2), Rotation::East);
REQUIRE(tilesOf(path) == std::vector<QPoint>{
QPoint(2, 0), QPoint(2, 1), QPoint(2, 2) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
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<BeltPathTile> 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>{
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(2, 1), QPoint(2, 2) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
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<BeltPathTile> path =
computeBeltDragPath(QPoint(0, 0), QPoint(-2, -2), Rotation::East);
REQUIRE(tilesOf(path) == std::vector<QPoint>{
QPoint(0, 0), QPoint(-1, 0), QPoint(-2, 0), QPoint(-2, -1),
QPoint(-2, -2) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
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<BeltPathTile> 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>{
QPoint(0, 0), QPoint(1, 0), QPoint(2, 0), QPoint(2, -1), QPoint(2, -2) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
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<BeltPathTile> 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>{
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(1, 2), QPoint(2, 2) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
Rotation::South, Rotation::South, Rotation::East, Rotation::East,
Rotation::East });
}
TEST_CASE("North orientation is vertical-first (down-left cursor)")
{
const std::vector<BeltPathTile> 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>{
QPoint(0, 0), QPoint(0, 1), QPoint(0, 2), QPoint(-1, 2), QPoint(-2, 2) });
REQUIRE(rotationsOf(path) == std::vector<Rotation>{
Rotation::South, Rotation::South, Rotation::West, Rotation::West,
Rotation::West });
}

View File

@@ -8,6 +8,7 @@ add_files(
SimulationTest.cpp
BeltSystemTest.cpp
SurfaceMaskTest.cpp
BeltDragPathTest.cpp
BuildingTest.cpp
BuildingConfigTest.cpp
ShipTest.cpp

View File

@@ -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::BeltDragResolved> GameWorldView::resolveBeltDragPath() const
{
std::vector<BeltDragResolved> 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<BuildingId> 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<BeltDragResolved> 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<RotateInPlaceCommand> command =
std::make_shared<RotateInPlaceCommand>();
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<BeltDragResolved> 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<BuilderModeExitedEvent>());

View File

@@ -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<BuildingId> 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<BeltDragResolved> 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<QPoint, QPointCompare> 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<BeltPathTile> m_beltDragPath;
QPoint m_beltDragAnchor;
bool m_dragging;
std::optional<Blueprint> m_blueprintMode;