diff --git a/bin/app/data/config/visuals.toml b/bin/app/data/config/visuals.toml index 513e49b..2669cfa 100644 --- a/bin/app/data/config/visuals.toml +++ b/bin/app/data/config/visuals.toml @@ -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) diff --git a/src/lib/core/BuildingType.cpp b/src/lib/core/BuildingType.cpp index bbbb819..8682360 100644 --- a/src/lib/core/BuildingType.cpp +++ b/src/lib/core/BuildingType.cpp @@ -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) diff --git a/src/lib/core/BuildingType.h b/src/lib/core/BuildingType.h index a54e696..a5c648e 100644 --- a/src/lib/core/BuildingType.h +++ b/src/lib/core/BuildingType.h @@ -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); diff --git a/src/lib/sim/PlacementRules.cpp b/src/lib/sim/PlacementRules.cpp index 135d351..97e54bc 100644 --- a/src/lib/sim/PlacementRules.cpp +++ b/src/lib/sim/PlacementRules.cpp @@ -69,16 +69,9 @@ bool isPlacementValid(const FactoryState& state, const GameConfig& config,Buildi } -std::optional findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config, - BuildingType type, QPoint anchor, Rotation rot) +std::optional 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 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 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 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 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) { diff --git a/src/lib/sim/PlacementRules.h b/src/lib/sim/PlacementRules.h index 8e964e1..a8a5c5a 100644 --- a/src/lib/sim/PlacementRules.h +++ b/src/lib/sim/PlacementRules.h @@ -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 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 findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config, BuildingType type, QPoint anchor, @@ -46,6 +64,36 @@ std::optional 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 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 { diff --git a/src/test/BuildingTest.cpp b/src/test/BuildingTest.cpp index 5af4252..d7c7dc8 100644 --- a/src/test/BuildingTest.cpp +++ b/src/test/BuildingTest.cpp @@ -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 // --------------------------------------------------------------------------- diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 4d73db9..a1e63cd 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -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 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 rotateCommand = - std::make_shared(); - 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 filterA = filterUnlockedItems(source.splitterFilterA, *m_sim); + const std::vector filterB = filterUnlockedItems(source.splitterFilterB, *m_sim); + if (const Building* building = findBuilding(m_sim->getFactoryState(), id)) + { + std::shared_ptr command = + std::make_shared(); + command->tile = building->anchor; + command->filterA = filterA; + command->filterB = filterB; + enqueueCommand(command); + } + else + { + std::shared_ptr command = + std::make_shared(); + command->id = id; + command->filterA = filterA; + command->filterB = filterB; + enqueueCommand(command); + } + return; + } + + std::shared_ptr recipeCommand = std::make_shared(); + 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 layoutCommand = + std::make_shared(); + 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 diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 4412bfc..6190727 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -187,6 +187,16 @@ private: std::optional 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(). diff --git a/src/ui/VisualsConfig.h b/src/ui/VisualsConfig.h index cb3d94f..39b29f9 100644 --- a/src/ui/VisualsConfig.h +++ b/src/ui/VisualsConfig.h @@ -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) diff --git a/src/ui/VisualsLoader.cpp b/src/ui/VisualsLoader.cpp index 3b6462a..5a0bc87 100644 --- a/src/ui/VisualsLoader.cpp +++ b/src/ui/VisualsLoader.cpp @@ -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"); diff --git a/src/ui/WorldRenderer.cpp b/src/ui/WorldRenderer.cpp index 7fd4063..531c2e2 100644 --- a/src/ui/WorldRenderer.cpp +++ b/src/ui/WorldRenderer.cpp @@ -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; } diff --git a/src/ui/WorldRenderer.h b/src/ui/WorldRenderer.h index 842a01d..d8be56c 100644 --- a/src/ui/WorldRenderer.h +++ b/src/ui/WorldRenderer.h @@ -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 // /../icons/buildings once at construction. Only the building