allow to rotate buildings in place

This commit is contained in:
2026-04-29 21:40:00 +02:00
parent 7e0104e9b8
commit 2770bf96be
4 changed files with 395 additions and 7 deletions

View File

@@ -797,6 +797,112 @@ bool BuildingSystem::isTileOccupied(QPoint tile) const
return m_tileOccupancy.count({tile.x(), tile.y()}) > 0; return m_tileOccupancy.count({tile.x(), tile.y()}) > 0;
} }
std::optional<EntityId> BuildingSystem::findRotateInPlaceTarget(
BuildingType type, QPoint anchor, Rotation rot) const
{
const BuildingDef* def = findBuildingDef(type);
if (!def) { return std::nullopt; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, rot);
if (mask.bodyCells.empty()) { return std::nullopt; }
// All body cells must be occupied by the same entity.
const QPoint firstAbs = anchor + mask.bodyCells[0];
const auto firstIt = m_tileOccupancy.find({firstAbs.x(), firstAbs.y()});
if (firstIt == m_tileOccupancy.end()) { return std::nullopt; }
const EntityId candidateId = firstIt->second;
for (const QPoint& rel : mask.bodyCells)
{
const QPoint abs = anchor + rel;
const auto it = m_tileOccupancy.find({abs.x(), abs.y()});
if (it == m_tileOccupancy.end() || it->second != candidateId)
{
return std::nullopt;
}
}
// Verify the candidate is the same building type with the same cell count.
for (const ConstructionSite& site : m_constructionQueue)
{
if (site.id != candidateId) { continue; }
if (site.type != type) { return std::nullopt; }
if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
return candidateId;
}
for (const Building& b : m_buildings)
{
if (b.id != candidateId) { continue; }
if (b.type != type) { return std::nullopt; }
if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
return candidateId;
}
return std::nullopt;
}
void BuildingSystem::rotateInPlace(EntityId id, Rotation newRotation)
{
// Construction site path — just update rotation; no ports to recompute.
for (ConstructionSite& site : m_constructionQueue)
{
if (site.id == id)
{
site.rotation = newRotation;
return;
}
}
// Operational building path.
for (Building& b : m_buildings)
{
if (b.id != id) { continue; }
b.rotation = newRotation;
const BuildingDef* def = findBuildingDef(b.type);
if (!def) { return; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, newRotation);
b.outputPorts.clear();
for (const Port& port : mask.outputPorts)
{
Port absPort;
absPort.tile = b.anchor + port.tile;
absPort.direction = port.direction;
b.outputPorts.push_back(absPort);
}
b.inputPorts = computeInputPorts(b);
// Re-register with BeltSystem (items on tile are discarded).
if (b.type == BuildingType::Belt)
{
m_belts.removeTile(b.anchor);
m_belts.placeBelt(b.anchor, newRotation);
}
else if (b.type == BuildingType::Splitter)
{
m_belts.removeTile(b.anchor);
assert(mask.outputPorts.size() >= 2);
m_belts.placeSplitter(b.anchor,
mask.outputPorts[0].direction,
mask.outputPorts[1].direction);
}
else if (b.type == BuildingType::TunnelEntry)
{
m_belts.removeTile(b.anchor);
m_belts.placeTunnelEntry(b.anchor, newRotation, m_config.world.tunnelMaxDistance);
}
else if (b.type == BuildingType::TunnelExit)
{
m_belts.removeTile(b.anchor);
m_belts.placeTunnelExit(b.anchor, newRotation);
}
return;
}
}
const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos, const Building* BuildingSystem::findNearestBuilding(QVector2D worldPos,
BuildingType type) const BuildingType type) const
{ {

View File

@@ -74,6 +74,18 @@ public:
std::vector<BeltTileInfo> allBeltTiles() const; std::vector<BeltTileInfo> allBeltTiles() const;
bool isTileOccupied(QPoint tile) const; bool isTileOccupied(QPoint tile) const;
// Returns the entity id of the building or construction site whose footprint
// exactly coincides with the ghost (type, anchor, rot) and is of the same
// building type. Returns nullopt otherwise.
std::optional<EntityId> findRotateInPlaceTarget(BuildingType type,
QPoint anchor,
Rotation rot) const;
// Rotate an existing building or construction site to newRotation in place.
// For belt-type operational buildings, re-registers with BeltSystem (items
// currently on the tile are discarded by BeltSystem::removeTile).
void rotateInPlace(EntityId id, Rotation newRotation);
// Find nearest operational building of the given type; nullptr if none. // Find nearest operational building of the given type; nullptr if none.
const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const; const Building* findNearestBuilding(QVector2D worldPos, BuildingType type) const;

View File

@@ -535,3 +535,245 @@ TEST_CASE("BuildingSystem: reprocessing plant produces one cycle output then sta
// No new production: inputs were consumed and not replenished. // No new production: inputs were consumed and not replenished.
REQUIRE_FALSE(b->production.has_value()); REQUIRE_FALSE(b->production.has_value());
} }
// ---------------------------------------------------------------------------
// findRotateInPlaceTarget
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when tile is empty",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the site id for a queued belt (same type, different rotation)",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const std::optional<EntityId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns the building id for a completed operational belt",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(bs.allSites().empty());
const std::optional<EntityId> result =
bs.findRotateInPlaceTarget(BuildingType::Belt, QPoint(0, 0), Rotation::South);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when building type differs",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
// Querying with Splitter at the same tile — type mismatch → nullopt.
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::Splitter, QPoint(0, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget returns nullopt when footprints only partially overlap",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
// Smelter at (0,0) occupies body tiles (0,0),(1,0),(0,1),(1,1).
bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
// Ghost anchored at (1,0) would cover (1,0),(2,0),(1,1),(2,1):
// only (1,0) and (1,1) are occupied — not a full coincidence.
REQUIRE_FALSE(
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(1, 0), Rotation::East).has_value());
}
TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-tile building with rotated ghost",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
// Smelter is a fully filled 2×2 footprint — rotating the ghost produces the
// same four body tiles, so findRotateInPlaceTarget must still return the id.
const EntityId id = bs.place(BuildingType::Smelter, QPoint(0, 0), Rotation::East, 0);
const std::optional<EntityId> result =
bs.findRotateInPlaceTarget(BuildingType::Smelter, QPoint(0, 0), Rotation::North);
REQUIRE(result.has_value());
REQUIRE(*result == id);
}
// ---------------------------------------------------------------------------
// rotateInPlace
// ---------------------------------------------------------------------------
TEST_CASE("BuildingSystem: rotateInPlace updates the rotation field of a construction site",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
REQUIRE(bs.findSite(id)->rotation == Rotation::East);
bs.rotateInPlace(id, Rotation::North);
REQUIRE(bs.findSite(id)->rotation == Rotation::North);
}
TEST_CASE("BuildingSystem: rotateInPlace preserves the construction progress of a queued site",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
const Tick completesAt = bs.findSite(id)->completesAt;
REQUIRE(completesAt > 0);
bs.rotateInPlace(id, Rotation::South);
REQUIRE(bs.findSite(id)->completesAt == completesAt);
}
TEST_CASE("BuildingSystem: rotateInPlace updates rotation and output port direction on an operational building",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
REQUIRE(bs.findBuilding(id) != nullptr);
const Building& before = *bs.findBuilding(id);
REQUIRE(before.outputPorts[0].direction == Rotation::East);
bs.rotateInPlace(id, Rotation::North);
const Building& after = *bs.findBuilding(id);
REQUIRE(after.rotation == Rotation::North);
REQUIRE(after.outputPorts[0].direction == Rotation::North);
}
TEST_CASE("BuildingSystem: rotateInPlace re-registers a belt tile with BeltSystem so it still accepts items",
"[building][rotate-in-place]")
{
const GameConfig cfg = loadConfig();
BeltSystem belts(cfg.world.beltSpeedTilesPerSecond);
int stock = 0;
std::mt19937 rng(0);
EntityId nextId = 1;
BuildingSystem bs(cfg, belts,
[&nextId]() { return nextId++; },
[&stock](int n) { stock += n; },
[](const std::string&, QVector2D) {},
rng);
const EntityId id = bs.place(BuildingType::Belt, QPoint(0, 0), Rotation::East, 0);
Tick tick = 0;
runTicks(bs, belts, static_cast<int>(secondsToTicks(1.0)) + 1, tick);
bs.rotateInPlace(id, Rotation::North);
// Belt tile must still be registered after rotation — items can be placed on it.
REQUIRE(belts.tryPutItem(QPoint(0, 0), makeItem("iron_ore")));
}

View File

@@ -384,6 +384,7 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot); const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rot);
bool anyOccupied = false;
for (const QPoint& relCell : parsed.bodyCells) for (const QPoint& relCell : parsed.bodyCells)
{ {
const QPoint worldCell = anchor + relCell; const QPoint worldCell = anchor + relCell;
@@ -401,8 +402,12 @@ bool GameWorldView::isValidPlacement(BuildingType type, QPoint anchor,
if (isShipDock && worldCell.x() < 0) { return false; } if (isShipDock && worldCell.x() < 0) { return false; }
if (!isShipDock && worldCell.x() >= 0) { return false; } if (!isShipDock && worldCell.x() >= 0) { return false; }
// Occupancy check if (m_sim->buildings().isTileOccupied(worldCell)) { anyOccupied = true; }
if (m_sim->buildings().isTileOccupied(worldCell)) { return false; } }
if (anyOccupied)
{
return m_sim->buildings().findRotateInPlaceTarget(type, anchor, rot).has_value();
} }
return true; return true;
} }
@@ -481,9 +486,15 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; } if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; }
} }
// Cost only applies to buildings that are genuinely new (not rotate-in-place).
int totalCost = 0; int totalCost = 0;
for (const BlueprintBuilding& bb : bp.buildings) for (const BlueprintBuilding& bb : bp.buildings)
{ {
if (m_sim->buildings().findRotateInPlaceTarget(
bb.type, center + bb.offset, bb.rotation).has_value())
{
continue;
}
const BuildingDef* def = findBuildingDef(bb.type); const BuildingDef* def = findBuildingDef(bb.type);
if (def) { totalCost += def->cost; } if (def) { totalCost += def->cost; }
} }
@@ -491,7 +502,16 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
for (const BlueprintBuilding& bb : bp.buildings) for (const BlueprintBuilding& bb : bp.buildings)
{ {
const EntityId id = m_sim->tryPlaceBuilding(bb.type, center + bb.offset, bb.rotation); const QPoint anchor = center + bb.offset;
const std::optional<EntityId> rotateTarget =
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
if (rotateTarget.has_value())
{
m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation);
continue;
}
const EntityId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
if (id == kInvalidEntityId || bb.recipeId.empty()) { continue; } if (id == kInvalidEntityId || bb.recipeId.empty()) { continue; }
if (bb.type == BuildingType::Shipyard) if (bb.type == BuildingType::Shipyard)
@@ -511,16 +531,24 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
void GameWorldView::placeAtTile(QPoint tile) void GameWorldView::placeAtTile(QPoint tile)
{ {
if (!m_builderType.has_value()) if (!m_builderType.has_value())
{ {
return; return;
} }
const BuildingType type = *m_builderType; const BuildingType type = *m_builderType;
if (!isValidPlacement(type, tile, m_ghostRotation)) if (!isValidPlacement(type, tile, m_ghostRotation))
{ {
return; return;
} }
const std::optional<EntityId> rotateTarget =
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
if (rotateTarget.has_value())
{
m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation);
return;
}
if (type == BuildingType::Belt) if (type == BuildingType::Belt)
{ {
if (m_beltDragTiles.count(tile) > 0) if (m_beltDragTiles.count(tile) > 0)