re-cover copy-settings through single-building blueprints
This commit is contained in:
@@ -53,6 +53,20 @@ bool isBeltSubsystemType(BuildingType type)
|
||||
|| type == BuildingType::TunnelExit;
|
||||
}
|
||||
|
||||
bool isConfigurableBuildingType(BuildingType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case BuildingType::Miner: // recipe (REQ-BLD-MINER)
|
||||
case BuildingType::Assembler: // recipe (REQ-BLD-ASSEMBLER)
|
||||
case BuildingType::Shipyard: // schematic and layout (REQ-BLD-SHIPYARD, REQ-MOD-LAYOUT)
|
||||
case BuildingType::Splitter: // output filters (REQ-BLD-SPLITTER)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool isProductionBuildingType(BuildingType type)
|
||||
{
|
||||
switch (type)
|
||||
|
||||
@@ -43,3 +43,9 @@ bool isProductionBuildingType(BuildingType type);
|
||||
// rather than in the Building instance, so placing/removing them must register or
|
||||
// unregister a tile with BeltSystem.
|
||||
bool isBeltSubsystemType(BuildingType type);
|
||||
|
||||
// Building types with player-facing settings that a blueprint can carry and hand to an
|
||||
// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner and Assembler (recipe), Shipyard
|
||||
// (schematic and module layout), Splitter (output filters). Every other type has nothing
|
||||
// to configure, so a blueprint of one has nothing to transfer.
|
||||
bool isConfigurableBuildingType(BuildingType type);
|
||||
|
||||
@@ -69,16 +69,9 @@ bool isPlacementValid(const FactoryState& state, const GameConfig& config,Buildi
|
||||
}
|
||||
|
||||
|
||||
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config,
|
||||
BuildingType type, QPoint anchor, Rotation rot)
|
||||
std::optional<CoincidingBuilding> findCoincidingSameTypeBuilding(const FactoryState& state,
|
||||
const GameConfig& config, BuildingType type, QPoint anchor, Rotation rot)
|
||||
{
|
||||
// Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a
|
||||
// tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE).
|
||||
if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const BuildingDef* def = config.buildings.findBuildingDef(type);
|
||||
if (!def) { return std::nullopt; }
|
||||
|
||||
@@ -106,20 +99,88 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, con
|
||||
if (site.id != candidateId) { continue; }
|
||||
if (site.type != type) { return std::nullopt; }
|
||||
if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
|
||||
return candidateId;
|
||||
return CoincidingBuilding{candidateId, site.rotation};
|
||||
}
|
||||
for (const Building& b : state.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 CoincidingBuilding{candidateId, b.rotation};
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
|
||||
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config,
|
||||
BuildingType type, QPoint anchor, Rotation rot)
|
||||
{
|
||||
// Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a
|
||||
// tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE).
|
||||
if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::optional<CoincidingBuilding> target =
|
||||
findCoincidingSameTypeBuilding(state, config, type, anchor, rot);
|
||||
if (!target.has_value()) { return std::nullopt; }
|
||||
return target->id;
|
||||
}
|
||||
|
||||
|
||||
BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, const GameConfig& config,
|
||||
BuildingType type, QPoint anchor, Rotation rotation, bool blueprintHoldsOneBuilding)
|
||||
{
|
||||
// Terrain and world bounds first: nothing rescues a ghost hanging off the asteroid
|
||||
// (REQ-BLD-PLACE-VALID condition (a)).
|
||||
if (!isPlacementValid(state, config, type, anchor, rotation))
|
||||
{
|
||||
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt};
|
||||
}
|
||||
|
||||
const std::optional<CoincidingBuilding> coinciding =
|
||||
findCoincidingSameTypeBuilding(state, config, type, anchor, rotation);
|
||||
if (coinciding.has_value())
|
||||
{
|
||||
// A single configurable building hands its settings over, whatever way the target
|
||||
// faces: a transfer never rotates anything, so the target's facing does not matter
|
||||
// (REQ-UI-BLUEPRINT-TRANSFER). Tested before the overlap rule, which then governs
|
||||
// only what this does not claim.
|
||||
if (blueprintHoldsOneBuilding && isConfigurableBuildingType(type))
|
||||
{
|
||||
return BlueprintGhostResolved{BlueprintGhostAction::Transfer, coinciding->id};
|
||||
}
|
||||
|
||||
// Otherwise the building the blueprint wants must already be there in full,
|
||||
// facing the same way, because nothing here may re-orient it
|
||||
// (REQ-UI-BLUEPRINT-OVERLAP). Tunnels are not excluded: nothing is rotated, so
|
||||
// the reason for their REQ-BLD-ROTATE-IN-PLACE exception does not arise.
|
||||
if (coinciding->rotation == rotation)
|
||||
{
|
||||
return BlueprintGhostResolved{BlueprintGhostAction::CompatibleOverlap,
|
||||
coinciding->id};
|
||||
}
|
||||
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt};
|
||||
}
|
||||
|
||||
// No coincidence: any occupancy at all is an ordinary overlap
|
||||
// (REQ-BLD-PLACE-VALID condition (b)).
|
||||
const BuildingDef* def = config.buildings.findBuildingDef(type);
|
||||
if (!def) { return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt}; }
|
||||
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
|
||||
for (const QPoint& relativeCell : parsed.bodyCells)
|
||||
{
|
||||
if (isTileOccupied(state, anchor + relativeCell))
|
||||
{
|
||||
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt};
|
||||
}
|
||||
}
|
||||
return BlueprintGhostResolved{BlueprintGhostAction::PlaceNew, std::nullopt};
|
||||
}
|
||||
|
||||
|
||||
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
|
||||
BuildingType type, QPoint anchor, Rotation rotation)
|
||||
{
|
||||
|
||||
@@ -30,9 +30,27 @@ bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& con
|
||||
bool isPlacementValid(const FactoryState& state, const GameConfig& config,
|
||||
BuildingType type, QPoint anchor, Rotation rotation);
|
||||
|
||||
// An existing building or site whose footprint a ghost exactly covers.
|
||||
struct CoincidingBuilding
|
||||
{
|
||||
BuildingId id;
|
||||
Rotation rotation;
|
||||
};
|
||||
|
||||
// The building or site whose footprint a ghost of the given type/anchor/rotation
|
||||
// exactly covers: same type, same body cells, a single owner. Operational buildings
|
||||
// and construction sites alike. Rotation is not part of the test -- the target's own
|
||||
// facing is reported so each caller can apply its own rule to it.
|
||||
std::optional<CoincidingBuilding> findCoincidingSameTypeBuilding(const FactoryState& state,
|
||||
const GameConfig& config,
|
||||
BuildingType type,
|
||||
QPoint anchor,
|
||||
Rotation rot);
|
||||
|
||||
// The building or site that a ghost of the given type/anchor/rotation would
|
||||
// rotate in place rather than replace: same type, same body cells, one owner
|
||||
// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify.
|
||||
// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify. Builder mode only; blueprint
|
||||
// placement mode never rotates anything -- see resolveBlueprintGhost.
|
||||
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
|
||||
const GameConfig& config,
|
||||
BuildingType type, QPoint anchor,
|
||||
@@ -46,6 +64,36 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
|
||||
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
|
||||
BuildingType type, QPoint anchor, Rotation rotation);
|
||||
|
||||
// What one ghost of a blueprint would do at its resolved tile.
|
||||
enum class BlueprintGhostAction
|
||||
{
|
||||
PlaceNew, // free, valid cells: a new construction site, charged for
|
||||
CompatibleOverlap, // the same building is already there: left untouched, free
|
||||
// (REQ-UI-BLUEPRINT-OVERLAP)
|
||||
Transfer, // hand this blueprint's settings to the building already there,
|
||||
// free (REQ-UI-BLUEPRINT-TRANSFER)
|
||||
Invalid // terrain, bounds, or an overlap that is neither of the above
|
||||
};
|
||||
|
||||
struct BlueprintGhostResolved
|
||||
{
|
||||
BlueprintGhostAction action;
|
||||
std::optional<BuildingId> targetId; // set for CompatibleOverlap and Transfer
|
||||
};
|
||||
|
||||
// Classifies one ghost of a blueprint against the current factory state
|
||||
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). `blueprintHoldsOneBuilding` is
|
||||
// the blueprint's stored size, counted before locked types are dropped, so the gesture
|
||||
// does not change behavior as the player unlocks things.
|
||||
//
|
||||
// Shared by the ghost coloring and the click path so a preview cannot disagree with what
|
||||
// the click then does -- the same reason resolveBeltDragPath is shared.
|
||||
BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state,
|
||||
const GameConfig& config,
|
||||
BuildingType type, QPoint anchor,
|
||||
Rotation rotation,
|
||||
bool blueprintHoldsOneBuilding);
|
||||
|
||||
// What a belt drag would do to one tile of its path (REQ-BLD-BELT-DRAG).
|
||||
enum class BeltTileAction
|
||||
{
|
||||
|
||||
@@ -1039,6 +1039,206 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
|
||||
REQUIRE(*result == id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveBlueprintGhost
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// What a blueprint ghost does where it meets an existing building
|
||||
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). Blueprint placement never
|
||||
// rotates anything, so a coinciding building must either take the blueprint's settings
|
||||
// or already match it exactly.
|
||||
|
||||
namespace
|
||||
{
|
||||
BlueprintGhostResolved resolveOne(const PlacementFixture& f, BuildingType type,
|
||||
QPoint anchor, Rotation rotation)
|
||||
{
|
||||
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation,
|
||||
/*blueprintHoldsOneBuilding*/ true);
|
||||
}
|
||||
|
||||
BlueprintGhostResolved resolveInConstellation(const PlacementFixture& f, BuildingType type,
|
||||
QPoint anchor, Rotation rotation)
|
||||
{
|
||||
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation,
|
||||
/*blueprintHoldsOneBuilding*/ false);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("isConfigurableBuildingType: only types with player-facing settings",
|
||||
"[blueprint]")
|
||||
{
|
||||
// The gate on whether a single-building blueprint transfers anything at all.
|
||||
CHECK(isConfigurableBuildingType(BuildingType::Miner));
|
||||
CHECK(isConfigurableBuildingType(BuildingType::Assembler));
|
||||
CHECK(isConfigurableBuildingType(BuildingType::Shipyard));
|
||||
CHECK(isConfigurableBuildingType(BuildingType::Splitter));
|
||||
|
||||
// Smelter and Reprocessing Plant run implicit recipes (REQ-BLD-SMELTER,
|
||||
// REQ-BLD-REPROCESSING) and the rest have no settings whatsoever.
|
||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter));
|
||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
|
||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay));
|
||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt));
|
||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry));
|
||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelExit));
|
||||
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Hq));
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: free valid cells place a new building", "[blueprint]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
|
||||
// Anchored on the asteroid (x < 0): a miner is all-asteroid cells. BuildingSystem's
|
||||
// place() skips the terrain rules, but resolveBlueprintGhost applies them.
|
||||
const BlueprintGhostResolved resolved =
|
||||
resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
CHECK(resolved.action == BlueprintGhostAction::PlaceNew);
|
||||
CHECK_FALSE(resolved.targetId.has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: terrain-invalid positions are invalid", "[blueprint]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
|
||||
// A miner is all-asteroid (A) cells, so it cannot sit out in space (x >= 0).
|
||||
CHECK(resolveOne(f, BuildingType::Miner, QPoint(5, 0), Rotation::East).action
|
||||
== BlueprintGhostAction::Invalid);
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: overlapping a different building type is invalid",
|
||||
"[blueprint]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
|
||||
f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0);
|
||||
|
||||
CHECK(resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::East).action
|
||||
== BlueprintGhostAction::Invalid);
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid",
|
||||
"[blueprint]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
|
||||
// Smelter at (-3,0) covers (-3,0),(-2,0),(-3,1),(-2,1); a ghost at (-2,0) covers only
|
||||
// two of those, so it coincides with nothing and is an ordinary occupied overlap.
|
||||
// Both footprints stay on the asteroid, so terrain is not what fails here.
|
||||
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
|
||||
|
||||
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
|
||||
== BlueprintGhostAction::Invalid);
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings",
|
||||
"[blueprint]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
|
||||
const BuildingId id =
|
||||
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
|
||||
|
||||
const BlueprintGhostResolved resolved =
|
||||
resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
|
||||
REQUIRE(resolved.targetId.has_value());
|
||||
CHECK(*resolved.targetId == id);
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: a transfer ignores the target's rotation", "[blueprint]")
|
||||
{
|
||||
// The rule transfer does not share with compatible overlap: a transfer never rotates
|
||||
// anything, so which way the target faces cannot matter (REQ-UI-BLUEPRINT-TRANSFER).
|
||||
PlacementFixture f;
|
||||
|
||||
const BuildingId id =
|
||||
f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0).value();
|
||||
|
||||
const BlueprintGhostResolved resolved =
|
||||
resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North);
|
||||
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
|
||||
REQUIRE(resolved.targetId.has_value());
|
||||
CHECK(*resolved.targetId == id);
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: a construction site is a transfer target too",
|
||||
"[blueprint]")
|
||||
{
|
||||
PlacementFixture f;
|
||||
|
||||
// Not ticked to completion, so it is still queued (REQ-BLD-SITE-CONFIG).
|
||||
const BuildingId id =
|
||||
f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();
|
||||
REQUIRE_FALSE(getAllSites(f.state).empty());
|
||||
|
||||
const BlueprintGhostResolved resolved =
|
||||
resolveOne(f, BuildingType::Assembler, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
|
||||
CHECK(*resolved.targetId == id);
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: a single building with no settings overlaps instead",
|
||||
"[blueprint]")
|
||||
{
|
||||
// A belt carries nothing to transfer, so the same footprint is a compatible overlap
|
||||
// when the facings match -- and invalid when they do not, since nothing here may
|
||||
// re-orient it (REQ-UI-BLUEPRINT-OVERLAP).
|
||||
PlacementFixture f;
|
||||
|
||||
const BuildingId id =
|
||||
f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0).value();
|
||||
|
||||
const BlueprintGhostResolved matching =
|
||||
resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
|
||||
REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap);
|
||||
CHECK(*matching.targetId == id);
|
||||
|
||||
CHECK(resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::North).action
|
||||
== BlueprintGhostAction::Invalid);
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: nothing in a multi-building blueprint transfers",
|
||||
"[blueprint]")
|
||||
{
|
||||
// Even a configurable building coinciding with its twin only overlaps once the
|
||||
// blueprint holds more than one building (REQ-UI-BLUEPRINT-TRANSFER).
|
||||
PlacementFixture f;
|
||||
|
||||
const BuildingId id =
|
||||
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
|
||||
|
||||
const BlueprintGhostResolved matching =
|
||||
resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap);
|
||||
CHECK(*matching.targetId == id);
|
||||
|
||||
// ... and a differently-facing twin blocks the whole constellation.
|
||||
CHECK(resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::North).action
|
||||
== BlueprintGhostAction::Invalid);
|
||||
}
|
||||
|
||||
TEST_CASE("resolveBlueprintGhost: an identical tunnel is a compatible overlap", "[blueprint]")
|
||||
{
|
||||
// findRotateInPlaceTarget refuses tunnels because re-orienting one is unsupported.
|
||||
// Nothing is re-oriented here, so that reason does not apply and the tunnel the
|
||||
// blueprint wants -- already there, same facing -- is simply left alone.
|
||||
PlacementFixture f;
|
||||
|
||||
const BuildingId id =
|
||||
f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0).value();
|
||||
f.bs.place(f.state, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);
|
||||
|
||||
REQUIRE_FALSE(
|
||||
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East)
|
||||
.has_value());
|
||||
|
||||
const BlueprintGhostResolved resolved =
|
||||
resolveInConstellation(f, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East);
|
||||
REQUIRE(resolved.action == BlueprintGhostAction::CompatibleOverlap);
|
||||
CHECK(*resolved.targetId == id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rotateInPlace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -563,16 +563,21 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
// Locked building types are excluded from this placement entirely
|
||||
// (REQ-LOCK-BUILDING, REQ-LOCK-UI-BLUEPRINT): not validity-checked here.
|
||||
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
|
||||
if (!canPlaceBuildingHere(bb.type, center + bb.offset, bb.rotation)) { return; }
|
||||
if (resolveBlueprintGhostHere(bb, center).action == BlueprintGhostAction::Invalid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cost only applies to buildings that are genuinely new (not rotate-in-place),
|
||||
// and excludes locked building types (REQ-LOCK-UI-BLUEPRINT).
|
||||
// Only genuinely new buildings are charged for: a compatible overlap places nothing
|
||||
// (REQ-UI-BLUEPRINT-OVERLAP) and a transfer changes settings only
|
||||
// (REQ-UI-BLUEPRINT-TRANSFER). Locked building types are excluded from the total
|
||||
// as well (REQ-LOCK-UI-BLUEPRINT).
|
||||
int totalCost = 0;
|
||||
for (const BlueprintBuilding& bb : bp.buildings)
|
||||
{
|
||||
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
|
||||
if (findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, center + bb.offset, bb.rotation).has_value())
|
||||
if (resolveBlueprintGhostHere(bb, center).action != BlueprintGhostAction::PlaceNew)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -585,15 +590,15 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
{
|
||||
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
|
||||
const QPoint anchor = center + bb.offset;
|
||||
const std::optional<BuildingId> rotateTarget =
|
||||
findRotateInPlaceTarget(m_sim->getFactoryState(), m_sim->getConfig(), bb.type, anchor, bb.rotation);
|
||||
if (rotateTarget.has_value())
|
||||
const BlueprintGhostResolved resolved = resolveBlueprintGhostHere(bb, center);
|
||||
|
||||
// The building the blueprint wants is already there, facing the same way: leave
|
||||
// it exactly as it is (REQ-UI-BLUEPRINT-OVERLAP).
|
||||
if (resolved.action == BlueprintGhostAction::CompatibleOverlap) { continue; }
|
||||
|
||||
if (resolved.action == BlueprintGhostAction::Transfer)
|
||||
{
|
||||
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
|
||||
std::make_shared<RotateInPlaceCommand>();
|
||||
rotateCommand->id = *rotateTarget;
|
||||
rotateCommand->newRotation = bb.rotation;
|
||||
enqueueCommand(rotateCommand);
|
||||
transferConfigTo(*resolved.targetId, bb);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -606,26 +611,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
command->anchor = anchor;
|
||||
command->rotation = bb.rotation;
|
||||
|
||||
if (!bb.recipeId.empty())
|
||||
{
|
||||
if (bb.type == BuildingType::Shipyard)
|
||||
{
|
||||
if (m_sim->isSchematicUnlocked(bb.recipeId))
|
||||
{
|
||||
command->recipeId = bb.recipeId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
const bool needsUnlockCheck = bb.type == BuildingType::Miner
|
||||
|| bb.type == BuildingType::Assembler;
|
||||
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
|
||||
{
|
||||
command->recipeId = bb.recipeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
command->recipeId = unlockedRecipeId(bb);
|
||||
command->shipLayout = bb.shipLayout;
|
||||
|
||||
if (bb.type == BuildingType::Splitter
|
||||
@@ -643,6 +629,93 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
}
|
||||
}
|
||||
|
||||
BlueprintGhostResolved GameWorldView::resolveBlueprintGhostHere(const BlueprintBuilding& building,
|
||||
QPoint center) const
|
||||
{
|
||||
// The one-building test reads the blueprint as stored, before locked types are
|
||||
// dropped, so the gesture behaves the same however much the player has unlocked
|
||||
// (REQ-UI-BLUEPRINT-TRANSFER).
|
||||
return resolveBlueprintGhost(m_sim->getFactoryState(), m_sim->getConfig(),
|
||||
building.type, center + building.offset, building.rotation,
|
||||
m_buildMode.getBlueprint().buildings.size() == 1);
|
||||
}
|
||||
|
||||
std::string GameWorldView::unlockedRecipeId(const BlueprintBuilding& building) const
|
||||
{
|
||||
// A stored recipe or schematic is applied only while it is unlocked; a locked one
|
||||
// yields no id at all rather than a stale one (REQ-LOCK-UI-BLUEPRINT). Shared by
|
||||
// placement and configuration transfer so the two cannot gate differently.
|
||||
if (building.recipeId.empty()) { return std::string(); }
|
||||
|
||||
if (building.type == BuildingType::Shipyard)
|
||||
{
|
||||
return m_sim->isSchematicUnlocked(building.recipeId) ? building.recipeId
|
||||
: std::string();
|
||||
}
|
||||
|
||||
const bool needsUnlockCheck = building.type == BuildingType::Miner
|
||||
|| building.type == BuildingType::Assembler;
|
||||
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(building.recipeId))
|
||||
{
|
||||
return building.recipeId;
|
||||
}
|
||||
return std::string();
|
||||
}
|
||||
|
||||
void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& source)
|
||||
{
|
||||
// Hands the blueprint's settings to a building that is already there, changing
|
||||
// nothing else -- no construction site, no cost, no rotation
|
||||
// (REQ-UI-BLUEPRINT-TRANSFER). Every field is sent unconditionally, so a field the
|
||||
// blueprint has nothing stored for clears the target's rather than leaving it: the
|
||||
// target ends up identical to the source. Setting a value the building already holds
|
||||
// is a no-op in the simulation (REQ-MAT-INPUT-BUFFER), which is what keeps clicking
|
||||
// an already-matching building free of buffer and production-progress loss.
|
||||
if (source.type == BuildingType::Splitter)
|
||||
{
|
||||
// Operational splitters are configured by tile, sites by BuildingId (mirrors
|
||||
// SelectedBuildingPanel::onSplitterFilterChanged). Locked item types are dropped
|
||||
// per REQ-LOCK-UI-BLUEPRINT.
|
||||
const std::vector<ItemType> filterA = filterUnlockedItems(source.splitterFilterA, *m_sim);
|
||||
const std::vector<ItemType> filterB = filterUnlockedItems(source.splitterFilterB, *m_sim);
|
||||
if (const Building* building = findBuilding(m_sim->getFactoryState(), id))
|
||||
{
|
||||
std::shared_ptr<SetSplitterFiltersCommand> command =
|
||||
std::make_shared<SetSplitterFiltersCommand>();
|
||||
command->tile = building->anchor;
|
||||
command->filterA = filterA;
|
||||
command->filterB = filterB;
|
||||
enqueueCommand(command);
|
||||
}
|
||||
else
|
||||
{
|
||||
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
|
||||
std::make_shared<SetSiteSplitterFiltersCommand>();
|
||||
command->id = id;
|
||||
command->filterA = filterA;
|
||||
command->filterB = filterB;
|
||||
enqueueCommand(command);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
std::shared_ptr<SetRecipeCommand> recipeCommand = std::make_shared<SetRecipeCommand>();
|
||||
recipeCommand->id = id;
|
||||
recipeCommand->recipeId = unlockedRecipeId(source);
|
||||
enqueueCommand(recipeCommand);
|
||||
|
||||
// After the schematic, never before: a genuine schematic change resets the layout,
|
||||
// and both commands drain in order at the next tick boundary.
|
||||
if (source.type == BuildingType::Shipyard)
|
||||
{
|
||||
std::shared_ptr<SetShipLayoutCommand> layoutCommand =
|
||||
std::make_shared<SetShipLayoutCommand>();
|
||||
layoutCommand->id = id;
|
||||
layoutCommand->layout = source.shipLayout.value_or(ShipLayoutConfig{});
|
||||
enqueueCommand(layoutCommand);
|
||||
}
|
||||
}
|
||||
|
||||
void GameWorldView::updateTunnelGhost()
|
||||
{
|
||||
// The connection preview and entry/exit switch only apply at a valid placement
|
||||
|
||||
@@ -187,6 +187,16 @@ private:
|
||||
std::optional<BuildingId> siteAtTile(QPoint tile) const;
|
||||
|
||||
void placeBlueprintAtTile(QPoint center);
|
||||
// resolveBlueprintGhost (PlacementRules) for one building of the blueprint currently
|
||||
// in placement mode, anchored at the cursor tile `center`.
|
||||
BlueprintGhostResolved resolveBlueprintGhostHere(const BlueprintBuilding& building,
|
||||
QPoint center) const;
|
||||
// The blueprint's stored recipe or schematic id, or empty when it stores none or the
|
||||
// stored one is currently locked (REQ-LOCK-UI-BLUEPRINT).
|
||||
std::string unlockedRecipeId(const BlueprintBuilding& building) const;
|
||||
// Applies a single-building blueprint's settings to the building it is hovering,
|
||||
// instead of placing anything (REQ-UI-BLUEPRINT-TRANSFER).
|
||||
void transferConfigTo(BuildingId id, const BlueprintBuilding& source);
|
||||
|
||||
// Drops despawned or fully-collected debris from the selection
|
||||
// (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
|
||||
|
||||
@@ -48,6 +48,7 @@ struct OverlayVisuals
|
||||
QColor selectionRect;
|
||||
QColor tileHighlight;
|
||||
QColor selectedOutline;
|
||||
QColor configTransfer; // blueprint ghost over a transfer target (REQ-UI-BLUEPRINT-TRANSFER)
|
||||
QColor lockedAsteroid;
|
||||
QColor modalDim;
|
||||
QColor tunnelPreview; // tunnel connection preview highlight (REQ-BLD-TUNNEL-MODE)
|
||||
|
||||
@@ -224,6 +224,7 @@ VisualsConfig VisualsLoader::load(const std::string& path)
|
||||
cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect");
|
||||
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
|
||||
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
|
||||
cfg.overlays.configTransfer = parseColor(requireString(ov, "config_transfer", "overlays"), "overlays.config_transfer");
|
||||
cfg.overlays.lockedAsteroid = parseColor(requireString(ov, "locked_asteroid", "overlays"), "overlays.locked_asteroid");
|
||||
cfg.overlays.modalDim = parseColor(requireString(ov, "modal_dim", "overlays"), "overlays.modal_dim");
|
||||
cfg.overlays.tunnelPreview = parseColor(requireString(ov, "tunnel_preview", "overlays"), "overlays.tunnel_preview");
|
||||
|
||||
@@ -928,7 +928,8 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
|
||||
const BeltPathTile& entry = frame.buildMode.getBeltDragPath()[index];
|
||||
drawBuildingGhost(painter, coordinates, BuildingType::Belt,
|
||||
entry.tile, entry.rotation,
|
||||
/*valid*/ item.action != BeltTileAction::Invalid,
|
||||
item.action != BeltTileAction::Invalid
|
||||
? GhostTint::Normal : GhostTint::Invalid,
|
||||
/*showPortTargetGlyphs*/ true);
|
||||
}
|
||||
}
|
||||
@@ -960,7 +961,8 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
|
||||
drawBuildingGhost(painter, coordinates,
|
||||
frame.buildMode.getEffectiveBuilderType(),
|
||||
ghostTile, frame.buildMode.getGhostRotation(),
|
||||
frame.buildMode.isGhostValid(),
|
||||
frame.buildMode.isGhostValid()
|
||||
? GhostTint::Normal : GhostTint::Invalid,
|
||||
/*showPortTargetGlyphs*/ true);
|
||||
}
|
||||
}
|
||||
@@ -968,15 +970,34 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
|
||||
// Blueprint placement ghost
|
||||
if (frame.buildMode.isBlueprintMode())
|
||||
{
|
||||
// The stored building count, not the count after locked types are dropped, so the
|
||||
// transfer rule does not shift as the player unlocks things
|
||||
// (REQ-UI-BLUEPRINT-TRANSFER).
|
||||
const bool holdsOneBuilding =
|
||||
frame.buildMode.getBlueprint().buildings.size() == 1;
|
||||
for (const BlueprintBuilding& bb : frame.buildMode.getBlueprint().buildings)
|
||||
{
|
||||
// Locked building types are omitted from the blueprint (REQ-LOCK-BUILDING,
|
||||
// REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either.
|
||||
if (!m_sim.isBuildingUnlocked(bb.type)) { continue; }
|
||||
const QPoint anchor = frame.buildMode.getBlueprintGhostTile() + bb.offset;
|
||||
const bool valid = canPlaceBuilding(m_sim.getFactoryState(), m_sim.getConfig(), bb.type, anchor, bb.rotation);
|
||||
// The same classifier the click path uses, so the color always predicts what
|
||||
// clicking would do (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). A
|
||||
// compatible overlap is an ordinary valid ghost.
|
||||
const BlueprintGhostResolved resolved = resolveBlueprintGhost(
|
||||
m_sim.getFactoryState(), m_sim.getConfig(), bb.type, anchor, bb.rotation,
|
||||
holdsOneBuilding);
|
||||
GhostTint tint = GhostTint::Normal;
|
||||
if (resolved.action == BlueprintGhostAction::Transfer)
|
||||
{
|
||||
tint = GhostTint::Transfer;
|
||||
}
|
||||
else if (resolved.action == BlueprintGhostAction::Invalid)
|
||||
{
|
||||
tint = GhostTint::Invalid;
|
||||
}
|
||||
drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation,
|
||||
valid, /*showPortTargetGlyphs*/ false);
|
||||
tint, /*showPortTargetGlyphs*/ false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1048,7 +1069,7 @@ void WorldRenderer::drawBuildingGhost(QPainter& painter,
|
||||
const WorldCoordinates& coordinates,
|
||||
BuildingType type,
|
||||
QPoint anchorTile, Rotation rotation,
|
||||
bool valid, bool showPortTargetGlyphs)
|
||||
GhostTint tint, bool showPortTargetGlyphs)
|
||||
{
|
||||
const BuildingDef* def = m_sim.getConfig().buildings.findBuildingDef(type);
|
||||
if (!def) { return; }
|
||||
@@ -1058,15 +1079,18 @@ void WorldRenderer::drawBuildingGhost(QPainter& painter,
|
||||
if (it == m_visuals.buildings.end()) { return; }
|
||||
const BuildingVisuals& bv = it->second;
|
||||
|
||||
// Valid ghosts show the building type's own colors; invalid ghosts override
|
||||
// with the distinct invalid color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The
|
||||
// invalid color's RGB is taken at full opacity so it does not double-dim
|
||||
// against the setOpacity below (the configured color carries its own alpha).
|
||||
const QColor invalidColor(m_visuals.overlays.ghostInvalid.red(),
|
||||
m_visuals.overlays.ghostInvalid.green(),
|
||||
m_visuals.overlays.ghostInvalid.blue());
|
||||
const QColor fillColor = valid ? bv.fill : invalidColor;
|
||||
const QColor lineColor = valid ? bv.outline : invalidColor;
|
||||
// Normal ghosts show the building type's own colors; the other two tints override
|
||||
// them with a single flat color -- invalid (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID) or
|
||||
// configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). An override's RGB is taken at
|
||||
// full opacity so it does not double-dim against the setOpacity below (the
|
||||
// configured color carries its own alpha).
|
||||
const QColor& configured = tint == GhostTint::Transfer
|
||||
? m_visuals.overlays.configTransfer
|
||||
: m_visuals.overlays.ghostInvalid;
|
||||
const QColor overrideColor(configured.red(), configured.green(), configured.blue());
|
||||
const bool useOwnColors = tint == GhostTint::Normal;
|
||||
const QColor fillColor = useOwnColors ? bv.fill : overrideColor;
|
||||
const QColor lineColor = useOwnColors ? bv.outline : overrideColor;
|
||||
|
||||
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
|
||||
if (parsed.bodyCells.empty()) { return; }
|
||||
|
||||
@@ -35,6 +35,16 @@ struct ActiveBeam
|
||||
QVector2D targetOffset;
|
||||
};
|
||||
|
||||
// How a placement ghost is colored. Normal shows the building type's own colors
|
||||
// (REQ-BLD-GHOST); the other two override them (REQ-BLD-PLACE-VALID,
|
||||
// REQ-UI-BLUEPRINT-TRANSFER).
|
||||
enum class GhostTint
|
||||
{
|
||||
Normal,
|
||||
Invalid,
|
||||
Transfer
|
||||
};
|
||||
|
||||
// Everything the renderer draws that the simulation does not know about: what the
|
||||
// player has selected, which build mode is active, and the other transient bits of
|
||||
// interaction state the view owns. Assembled fresh each frame and passed by
|
||||
@@ -113,7 +123,7 @@ private:
|
||||
bool centered);
|
||||
void drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates,
|
||||
BuildingType type, QPoint anchorTile, Rotation rotation,
|
||||
bool valid, bool showPortTargetGlyphs);
|
||||
GhostTint tint, bool showPortTargetGlyphs);
|
||||
|
||||
// Loads the per-building world icons (REQ-UI-WORLD-ICON) from
|
||||
// <configDir>/../icons/buildings once at construction. Only the building
|
||||
|
||||
Reference in New Issue
Block a user