implement blueprint compatible overlap and configuration transfer

Implements the two rules specified in 128bf81, which restore the
copy-settings capability removed in 648241b as a property of blueprint
placement: press C on one configured building, then click same-type
buildings to stamp its settings across the factory.

The ghost colour and the click path already shared one predicate,
canPlaceBuilding, which is what kept preview and outcome from disagreeing.
The new rules make that answer four-valued, so the shared predicate becomes
a shared classifier: resolveBlueprintGhost in PlacementRules returns
PlaceNew / CompatibleOverlap / Transfer / Invalid, and both callers switch
on it. Putting it in lib rather than in the view is the whole testability
story -- src/ui is off the test include path.

findRotateInPlaceTarget is now the tunnel guard plus a shared
findCoincidingSameTypeBuilding core, so the two rules cannot drift apart;
its existing tests pass untouched. Blueprint placement no longer emits
RotateInPlaceCommand at all. Builder mode and the belt drag are unchanged.

transferConfigTo sends every field unconditionally, so a field the
blueprint stores nothing for clears the target's rather than leaving it.
The simulation's unchanged-value guards absorb the no-op case, which is
what keeps clicking an already-matching building free of buffer and
production-progress loss.

drawBuildingGhost's bool valid becomes GhostTint{Normal,Invalid,Transfer};
the transfer colour is taken at full RGB like the invalid one so it does
not double-dim against the ghost opacity. visuals.toml regains the overlay
colour under the name config_transfer, with its VisualsConfig field and
loader line -- overlay keys are mandatory, so the three move together.

The six new resolveBlueprintGhost cases failed on first run because the
test buildings were anchored in space: BuildingSystem::place skips the
terrain rules, resolveBlueprintGhost applies them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
This commit is contained in:
2026-08-06 19:20:34 +02:00
parent 250da8c9aa
commit 3cf35b669e
12 changed files with 508 additions and 59 deletions

View File

@@ -348,6 +348,7 @@ deconstruct_tint = "#ff000033" # deconstruct-mode hover tint
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT)
tile_highlight = "#ffffff22" # tile under cursor
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
config_transfer = "#33ccff66" # blueprint ghost over a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER)
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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; }

View File

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