re-cover copy-settings through single-building blueprints

This commit is contained in:
2026-08-06 19:44:55 +02:00
parent 9a3b6c10d6
commit 08d8b0dd90
13 changed files with 542 additions and 65 deletions

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