fix blueprint rotation bug

This commit is contained in:
2026-04-26 22:36:49 +02:00
parent 1e2135dc5b
commit 7859b38d62
2 changed files with 143 additions and 12 deletions

View File

@@ -7,12 +7,14 @@
#include <QPoint> #include <QPoint>
#include "Blueprint.h" #include "Blueprint.h"
#include "BuildingsConfig.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ConfigLoader.h" #include "ConfigLoader.h"
#include "EntityId.h" #include "EntityId.h"
#include "Rotation.h" #include "Rotation.h"
#include "Simulation.h" #include "Simulation.h"
#include "SurfaceMask.h"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers that mirror the production implementations under test. // Helpers that mirror the production implementations under test.
@@ -87,21 +89,51 @@ static Blueprint buildBlueprint(const std::vector<BuildingSpec>& specs)
} }
// Apply one CW/CCW rotation to every building in the constellation, mirroring // Apply one CW/CCW rotation to every building in the constellation, mirroring
// the Q / E handling in GameWorldView::keyPressEvent. // the corrected Q / E handling in GameWorldView::keyPressEvent.
static void applyRotationCW(Blueprint& bp) // Each building's anchor is recomputed from the rotated body cells so that
// multi-tile footprints stay correctly aligned after rotation.
static void applyRotationCW(Blueprint& bp, const GameConfig& cfg)
{ {
for (BlueprintBuilding& bb : bp.buildings) for (BlueprintBuilding& bb : bp.buildings)
{ {
bb.offset = rotateCW(bb.offset); const BuildingDef* def = nullptr;
for (const BuildingDef& d : cfg.buildings.buildings)
{
if (d.type == bb.type) { def = &d; break; }
}
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, -abs.y());
minY = std::min(minY, abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotCW(bb.rotation); bb.rotation = rotCW(bb.rotation);
} }
} }
static void applyRotationCCW(Blueprint& bp) static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
{ {
for (BlueprintBuilding& bb : bp.buildings) for (BlueprintBuilding& bb : bp.buildings)
{ {
bb.offset = rotateCCW(bb.offset); const BuildingDef* def = nullptr;
for (const BuildingDef& d : cfg.buildings.buildings)
{
if (d.type == bb.type) { def = &d; break; }
}
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, abs.y());
minY = std::min(minY, -abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotCCW(bb.rotation); bb.rotation = rotCCW(bb.rotation);
} }
} }
@@ -249,6 +281,7 @@ TEST_CASE("Blueprint: non-axis-aligned offset rotates correctly", "[blueprint]")
TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: CW constellation rotation updates offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig();
// Building one tile to the right, facing East. // Building one tile to the right, facing East.
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
@@ -258,7 +291,7 @@ TEST_CASE("Blueprint: CW constellation rotation updates offset and building rota
bb.offset = QPoint(1, 0); bb.offset = QPoint(1, 0);
bp.buildings.push_back(bb); bp.buildings.push_back(bb);
applyRotationCW(bp); applyRotationCW(bp, cfg);
// Offset: right → down. // Offset: right → down.
REQUIRE(bp.buildings[0].offset == QPoint(0, 1)); REQUIRE(bp.buildings[0].offset == QPoint(0, 1));
@@ -268,6 +301,7 @@ TEST_CASE("Blueprint: CW constellation rotation updates offset and building rota
TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig();
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
BlueprintBuilding bb; BlueprintBuilding bb;
@@ -276,7 +310,7 @@ TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rot
bb.offset = QPoint(1, 0); bb.offset = QPoint(1, 0);
bp.buildings.push_back(bb); bp.buildings.push_back(bb);
applyRotationCCW(bp); applyRotationCCW(bp, cfg);
// Offset: right → up. // Offset: right → up.
REQUIRE(bp.buildings[0].offset == QPoint(0, -1)); REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
@@ -286,6 +320,7 @@ TEST_CASE("Blueprint: CCW constellation rotation updates offset and building rot
TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]") TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "[blueprint]")
{ {
const GameConfig cfg = loadConfig();
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
BlueprintBuilding bb; BlueprintBuilding bb;
@@ -297,7 +332,7 @@ TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "
const QPoint originalOffset = bb.offset; const QPoint originalOffset = bb.offset;
const Rotation originalRotation = bb.rotation; const Rotation originalRotation = bb.rotation;
for (int i = 0; i < 4; ++i) { applyRotationCW(bp); } for (int i = 0; i < 4; ++i) { applyRotationCW(bp, cfg); }
REQUIRE(bp.buildings[0].offset == originalOffset); REQUIRE(bp.buildings[0].offset == originalOffset);
REQUIRE(bp.buildings[0].rotation == originalRotation); REQUIRE(bp.buildings[0].rotation == originalRotation);
@@ -305,6 +340,7 @@ TEST_CASE("Blueprint: four CW rotations restore offset and building rotation", "
TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]") TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[blueprint]")
{ {
const GameConfig cfg = loadConfig();
// Two buildings left and right of center; after CW they should be above and below. // Two buildings left and right of center; after CW they should be above and below.
Blueprint bp; Blueprint bp;
bp.name = "test"; bp.name = "test";
@@ -315,7 +351,7 @@ TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[
right.offset = QPoint( 1, 0); right.offset = QPoint( 1, 0);
bp.buildings = { left, right }; bp.buildings = { left, right };
applyRotationCW(bp); applyRotationCW(bp, cfg);
// left (-1, 0) → CW → (0, -1) (above center) // left (-1, 0) → CW → (0, -1) (above center)
// right ( 1, 0) → CW → (0, 1) (below center) // right ( 1, 0) → CW → (0, 1) (below center)
@@ -325,6 +361,80 @@ TEST_CASE("Blueprint: multi-building constellation rotates symmetrically CW", "[
REQUIRE(bp.buildings[1].rotation == Rotation::South); REQUIRE(bp.buildings[1].rotation == Rotation::South);
} }
// ---------------------------------------------------------------------------
// Regression: multi-tile building rotation (the anchor-offset-only bug)
// ---------------------------------------------------------------------------
TEST_CASE("Blueprint: CW rotation keeps belt adjacent to miner output port", "[blueprint]")
{
const GameConfig cfg = loadConfig();
// East miner: anchor (0,0), body cells (0,0),(1,0),(0,1).
// Output port indicator '>' at (1,1) → port tile (1,1), direction East.
// Belt placed at (1,1) — the port exit tile.
// Constellation body cells: {(0,0),(1,0),(0,1),(1,1)}.
// Integer center: ((0+1)/2, (0+1)/2) = (0,0).
// Miner offset (0,0), belt offset (1,1).
Blueprint bp;
bp.name = "test";
BlueprintBuilding miner;
miner.type = BuildingType::Miner;
miner.rotation = Rotation::East;
miner.offset = QPoint(0, 0);
BlueprintBuilding belt;
belt.type = BuildingType::Belt;
belt.rotation = Rotation::East;
belt.offset = QPoint(1, 1); // port exit tile of East miner relative to its anchor
bp.buildings = { miner, belt };
applyRotationCW(bp, cfg);
// Miner body cells (0,0),(1,0),(0,1) rotated CW: (0,0),(0,1),(-1,0).
// New miner anchor = min(-1..0, 0..1) = (-1, 0).
REQUIRE(bp.buildings[0].offset == QPoint(-1, 0));
REQUIRE(bp.buildings[0].rotation == Rotation::South);
// Belt body cell (1,1) rotated CW: (-1, 1). New belt anchor = (-1, 1).
REQUIRE(bp.buildings[1].offset == QPoint(-1, 1));
REQUIRE(bp.buildings[1].rotation == Rotation::South);
// South miner port tile relative to its anchor is (0,1) (the 'v' indicator in ["AA","vA"]).
// Miner anchor in constellation = (-1,0). Port exit = (-1,0)+(0,1) = (-1,1) = belt anchor. ✓
REQUIRE(bp.buildings[1].offset == bp.buildings[0].offset + QPoint(0, 1));
}
TEST_CASE("Blueprint: CCW rotation keeps belt adjacent to miner output port", "[blueprint]")
{
const GameConfig cfg = loadConfig();
Blueprint bp;
bp.name = "test";
BlueprintBuilding miner;
miner.type = BuildingType::Miner;
miner.rotation = Rotation::East;
miner.offset = QPoint(0, 0);
BlueprintBuilding belt;
belt.type = BuildingType::Belt;
belt.rotation = Rotation::East;
belt.offset = QPoint(1, 1);
bp.buildings = { miner, belt };
applyRotationCCW(bp, cfg);
// Miner body cells (0,0),(1,0),(0,1) rotated CCW: (0,0),(0,-1),(1,0).
// New miner anchor = min(0..1, -1..0) = (0, -1).
REQUIRE(bp.buildings[0].offset == QPoint(0, -1));
REQUIRE(bp.buildings[0].rotation == Rotation::North);
// Belt body cell (1,1) rotated CCW: (1,-1). New belt anchor = (1,-1).
REQUIRE(bp.buildings[1].offset == QPoint(1, -1));
REQUIRE(bp.buildings[1].rotation == Rotation::North);
// North miner port tile relative to its anchor is (1,0) (the '^' indicator in ["A^","AA"]).
// Miner anchor in constellation = (0,-1). Port exit = (0,-1)+(1,0) = (1,-1) = belt anchor. ✓
REQUIRE(bp.buildings[1].offset == bp.buildings[0].offset + QPoint(1, 0));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Simulation-level: blueprint placement places buildings at correct tiles // Simulation-level: blueprint placement places buildings at correct tiles
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -2,6 +2,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <climits>
#include <cmath> #include <cmath>
#include <map> #include <map>
#include <string> #include <string>
@@ -936,7 +937,17 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
{ {
for (BlueprintBuilding& bb : m_blueprintMode->buildings) for (BlueprintBuilding& bb : m_blueprintMode->buildings)
{ {
bb.offset = QPoint(-bb.offset.y(), bb.offset.x()); const BuildingDef* def = findBuildingDef(bb.type);
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, -abs.y());
minY = std::min(minY, abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotateClockwise(bb.rotation); bb.rotation = rotateClockwise(bb.rotation);
} }
} }
@@ -951,7 +962,17 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
{ {
for (BlueprintBuilding& bb : m_blueprintMode->buildings) for (BlueprintBuilding& bb : m_blueprintMode->buildings)
{ {
bb.offset = QPoint(bb.offset.y(), -bb.offset.x()); const BuildingDef* def = findBuildingDef(bb.type);
if (!def) { continue; }
const ParsedSurfaceMask mask = parseSurfaceMask(def->surfaceMask, bb.rotation);
int minX = INT_MAX, minY = INT_MAX;
for (const QPoint& cell : mask.bodyCells)
{
const QPoint abs = bb.offset + cell;
minX = std::min(minX, abs.y());
minY = std::min(minY, -abs.x());
}
bb.offset = QPoint(minX, minY);
bb.rotation = rotateCounterClockwise(bb.rotation); bb.rotation = rotateCounterClockwise(bb.rotation);
} }
} }