target single-building transfers by hovering, not by footprint coincidence

Coincidence required the ghost's anchor to land exactly on the target's
anchor, so on a 2x2 assembler three of its four tiles read as an ordinary
overlap and went red: you had to find the top-left tile before the copy
gesture would fire. A single-building blueprint now finds its target by
hit-testing the cursor instead -- any body tile, any facing -- and its ghost
snaps onto the target so it shows what the click will act on.

A constellation is unchanged: it is placed as a layout, so its ghosts stay
where the blueprint puts them and still need coincidence plus a matching
rotation. The old "single-building blueprints ignore target rotation"
carve-out is gone, subsumed by hovering.

blueprintHoldsOneBuilding becomes std::optional<QPoint> hoverTile, which
carries both facts in one parameter: set means "single-building blueprint,
cursor here". BlueprintGhostResolved gains ghostAnchor/ghostRotation so the
snap comes out of the classifier both callers already share, rather than
the renderer working it out separately.

Note the shipyard, which I had cited as the motivating case, is not one: its
mask needs A cells on asteroid and S cells in space, and the asteroid edge
is vertical, so a rotated shipyard can never be placed at all. Rotation was
never the real problem here; alignment was.

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 20:24:32 +02:00
parent 49e1c0ef20
commit 64bc6cfe8f
6 changed files with 171 additions and 68 deletions

View File

@@ -606,7 +606,7 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
Clicking anywhere on an enabled card other than its delete icon closes the dialog and enters blueprint placement mode for that blueprint (REQ-UI-BLUEPRINT-MODE). A card is disabled when the player cannot currently afford its total cost; a disabled card is rendered dimmed and clicking it does nothing (consistent with REQ-UI-BUILD-DISABLED), and neither closes the dialog nor enters placement mode. The delete icon is always enabled regardless of whether the player can afford the blueprint.
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint (excluding any of a currently locked building type, REQ-LOCK-BUILDING, which is omitted entirely per REQ-LOCK-UI-BLUEPRINT) at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). A valid ghost uses its building type's semi-transparent per-building coloring (REQ-BLD-GHOST); an invalid ghost uses the distinct "invalid" color, as in single-building builder mode; a ghost over a configuration-transfer target uses the distinct "transfer" color instead of either (REQ-UI-BLUEPRINT-TRANSFER), and a ghost over a compatible overlap counts as valid and keeps the ordinary per-building coloring (REQ-UI-BLUEPRINT-OVERLAP). Pressing R / Shift+R rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Opening the blueprint selection dialog while placement mode is active (REQ-UI-BLUEPRINT-DIALOG) leaves the mode active — closing the dialog without picking a card returns to it unchanged — while clicking a card exits the current mode and enters blueprint placement mode for the newly picked blueprint.
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint (excluding any of a currently locked building type, REQ-LOCK-BUILDING, which is omitted entirely per REQ-LOCK-UI-BLUEPRINT) at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). A valid ghost uses its building type's semi-transparent per-building coloring (REQ-BLD-GHOST); an invalid ghost uses the distinct "invalid" color, as in single-building builder mode; a ghost over a configuration-transfer target uses the distinct "transfer" color instead of either and, for a single-building blueprint, is drawn snapped onto the target rather than at the blueprint's own position (REQ-UI-BLUEPRINT-TRANSFER); a ghost over a compatible overlap counts as valid and keeps the ordinary per-building coloring (REQ-UI-BLUEPRINT-OVERLAP). Pressing R / Shift+R rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Opening the blueprint selection dialog while placement mode is active (REQ-UI-BLUEPRINT-DIALOG) leaves the mode active — closing the dialog without picking a card returns to it unchanged — while clicking a card exits the current mode and enters blueprint placement mode for the newly picked blueprint.
- REQ-UI-BLUEPRINT-PLACE: This describes placing the blueprint's buildings as new construction sites. Ghosts sitting on a building that is already there are handled elsewhere and place nothing: a configuration-transfer target receives the blueprint's stored settings instead (REQ-UI-BLUEPRINT-TRANSFER), and a compatible overlap is left alone (REQ-UI-BLUEPRINT-OVERLAP). Both still take part in the single all-or-nothing click described here. Buildings of a currently locked building type (REQ-LOCK-BUILDING) are first excluded from the blueprint for this placement, per REQ-LOCK-UI-BLUEPRINT — they are not ghosted, not validity-checked, not placed, and their cost is excluded from the total. Left-clicking in blueprint placement mode then places the (remaining) blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. Buildings that are compatible overlaps (REQ-UI-BLUEPRINT-OVERLAP) or configuration-transfer targets (REQ-UI-BLUEPRINT-TRANSFER) are excluded from the total cost and are not placed, but do not block the placement; a transfer target additionally receives the blueprint's stored settings. If both conditions are met, a construction site is added to the build queue for each remaining building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. If splitter output filters are stored, they are applied to the construction site immediately and carry over when it finishes building (REQ-BLD-SITE-CONFIG). Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
@@ -616,9 +616,9 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
A coinciding same-type building whose **rotation differs** is not a compatible overlap: the blueprint cannot rotate it (REQ-BLD-ROTATE-IN-PLACE no longer applies here), so the position is an ordinary occupied-tile overlap and therefore invalid.
**Order of the two rules.** A ghost over a coinciding same-type building is tested for a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) first, and this requirement governs only what that test does not claim. Because a coinciding building of a **configurable** type always transfers, this requirement covers exactly the types that have nothing to configure:
**Order of the two rules.** A ghost is tested for a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) first, and this requirement governs only what that test does not claim. Because a coinciding building of a **configurable** type always transfers, this requirement covers exactly the types that have nothing to configure:
- **Configurable type** (Miner, Assembler, Shipyard, Splitter) — a transfer, never a compatible overlap. It is drawn in the transfer color and hands the blueprint's settings over.
- **Type with no settings** (Smelter, Reprocessing Plant, Salvage Bay, belt, tunnel end) — a compatible overlap if the rotation matches, invalid otherwise. There is nothing to hand over, so the building is simply left as it is and the ghost keeps its ordinary color.
- **Type with no settings** (Smelter, Reprocessing Plant, Salvage Bay, belt, tunnel end) — a compatible overlap if the rotation matches, invalid otherwise. There is nothing to hand over, so the building is simply left as it is and the ghost keeps its ordinary color. This holds for a single-building blueprint too: the cursor hit-test of REQ-UI-BLUEPRINT-TRANSFER applies only to configurable types, so hovering a belt with a belt blueprint still just overlaps it.
A single blueprint can hold both kinds at once, and each ghost is judged on its own: dropping a constellation over a partial copy of itself may reconfigure some of the buildings already there (cyan) while leaving others untouched (ordinary color) and placing the rest as new construction sites.
@@ -626,8 +626,9 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
A blueprint ghost is a **configuration-transfer target** when all of the following hold:
- The building is of a **configurable building type** — one with player-facing settings: Miner and Assembler (recipe), Shipyard (schematic and module layout), Splitter (output filters). Whether anything was actually configured at capture time is irrelevant; an unconfigured source transfers its unconfigured state (see below). Building types with no settings at all (Smelter, Reprocessing Plant, Salvage Bay, belts, tunnel entries and exits, the HQ) never transfer; a coinciding building of those types is a compatible overlap instead (REQ-UI-BLUEPRINT-OVERLAP).
- The ghost's footprint **exactly coincides** with the footprint of an existing placed building or construction site of the same building type: the same coincidence test as REQ-BLD-ROTATE-IN-PLACE. A ghost that merely overlaps such a building without coinciding is not a transfer target.
- The target has the **same rotation** as the ghost — which is what makes the position valid at all (REQ-UI-BLUEPRINT-OVERLAP), since blueprint placement may not re-orient anything. **Exception:** when the blueprint holds **exactly one** building, rotation is disregarded and a differently-facing target transfers exactly like an identically-facing one. A transfer never rotates anything, so for the deliberate one-click gesture the target's facing is beside the point; inside a constellation it still has to match, or the placement would be invalid there.
- There is a target, found in one of two ways depending on the blueprint's size, because the two are different gestures:
- **Single-building blueprint** — the target is the building or construction site **under the cursor**, if it is of the same type. There is no coincidence test at all: the target's rotation, its footprint, and its alignment with the ghost are all irrelevant. The ghost is drawn **snapped onto the target**, at the target's own anchor and rotation, so it shows what the click will act on rather than where a building would go. This is the copying gesture, and a footprint test made it unusable for buildings that are not square: a Shipyard rotated 90° covers different tiles altogether, so no amount of lining up would ever match a differently-facing one.
- **Multi-building blueprint** — the ghost's footprint must **exactly coincide** with the footprint of an existing building or site of the same type (the coincidence test of REQ-BLD-ROTATE-IN-PLACE), and that target must have the **same rotation** as the ghost, which is what makes the position valid at all (REQ-UI-BLUEPRINT-OVERLAP). A constellation is placed as a layout, so its ghosts stay where the blueprint puts them: nothing snaps to the cursor and nothing is re-oriented.
At a transfer target:
- The ghost is drawn in a distinct **transfer** color read from `visuals.toml [overlays]`, overriding both the per-building coloring and the "invalid" color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The position counts as valid despite the occupied tiles (REQ-BLD-PLACE-VALID condition (b)).

View File

@@ -130,22 +130,67 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, con
}
BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation, bool blueprintHoldsOneBuilding)
namespace
{
// Terrain and world bounds first: nothing rescues a ghost hanging off the asteroid
// The building or construction site occupying `tile`, if it is of `type`, together with
// where it stands. The single-building transfer gesture hit-tests the cursor with this
// instead of comparing footprints (REQ-UI-BLUEPRINT-TRANSFER).
std::optional<CoincidingBuilding> findSameTypeBuildingAt(const FactoryState& state,
BuildingType type, QPoint tile,
QPoint& anchorOut)
{
const std::optional<BuildingId> owner = state.grid.findOwner(tile);
if (!owner.has_value()) { return std::nullopt; }
if (const ConstructionSite* site = findSite(state, *owner))
{
if (site->type != type) { return std::nullopt; }
anchorOut = site->anchor;
return CoincidingBuilding{site->id, site->rotation};
}
if (const Building* building = findBuilding(state, *owner))
{
if (building->type != type) { return std::nullopt; }
anchorOut = building->anchor;
return CoincidingBuilding{building->id, building->rotation};
}
return std::nullopt;
}
} // namespace
BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, const GameConfig& config,
BuildingType type, QPoint anchor, Rotation rotation, std::optional<QPoint> hoverTile)
{
// The single-building gesture first, because it answers without looking at the
// ghost's own position at all: whatever same-type building the cursor is on takes the
// settings, at any facing, and the ghost snaps onto it (REQ-UI-BLUEPRINT-TRANSFER).
if (hoverTile.has_value() && isConfigurableBuildingType(type))
{
QPoint hoveredAnchor;
const std::optional<CoincidingBuilding> hovered =
findSameTypeBuildingAt(state, type, *hoverTile, hoveredAnchor);
if (hovered.has_value())
{
return BlueprintGhostResolved{BlueprintGhostAction::Transfer, hovered->id,
hoveredAnchor, hovered->rotation};
}
}
// Terrain and world bounds next: 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};
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt,
anchor, rotation};
}
const std::optional<CoincidingBuilding> coinciding =
findCoincidingSameTypeBuilding(state, config, type, anchor, rotation);
if (coinciding.has_value())
{
const bool configurable = isConfigurableBuildingType(type);
// The building the blueprint wants is already there, facing the same way. It
// takes the blueprint's settings if it has any to take (REQ-UI-BLUEPRINT-TRANSFER)
// and is otherwise left exactly as it is (REQ-UI-BLUEPRINT-OVERLAP). Tunnels are
@@ -153,36 +198,36 @@ BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, const Ga
// REQ-BLD-ROTATE-IN-PLACE exception does not arise.
if (coinciding->rotation == rotation)
{
return BlueprintGhostResolved{configurable
return BlueprintGhostResolved{isConfigurableBuildingType(type)
? BlueprintGhostAction::Transfer
: BlueprintGhostAction::CompatibleOverlap,
coinciding->id};
coinciding->id, anchor, rotation};
}
// Facing the other way. Placement may not re-orient it, so this is invalid --
// except for the deliberate one-click gesture of a single-building blueprint,
// where a transfer rotates nothing anyway and the target's facing is beside the
// point (REQ-UI-BLUEPRINT-TRANSFER).
if (blueprintHoldsOneBuilding && configurable)
{
return BlueprintGhostResolved{BlueprintGhostAction::Transfer, coinciding->id};
}
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt};
// Facing the other way, and placement may not re-orient it.
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt,
anchor, rotation};
}
// 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}; }
if (!def)
{
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt,
anchor, rotation};
}
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::Invalid, std::nullopt,
anchor, rotation};
}
}
return BlueprintGhostResolved{BlueprintGhostAction::PlaceNew, std::nullopt};
return BlueprintGhostResolved{BlueprintGhostAction::PlaceNew, std::nullopt,
anchor, rotation};
}

View File

@@ -79,14 +79,24 @@ struct BlueprintGhostResolved
{
BlueprintGhostAction action;
std::optional<BuildingId> targetId; // set for CompatibleOverlap and Transfer
// Where the ghost belongs on screen. The queried anchor and rotation, except at a
// hovered transfer target, where the ghost snaps onto the target so it shows what the
// click will act on (REQ-UI-BLUEPRINT-TRANSFER).
QPoint ghostAnchor;
Rotation ghostRotation;
};
// Classifies one ghost of a blueprint against the current factory state
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). `blueprintHoldsOneBuilding`
// only widens the transfer rule to differently-facing targets; a same-facing target
// transfers whatever the blueprint's size. It is the blueprint's stored size, counted
// before locked types are dropped, so the gesture does not change behavior as the player
// unlocks things.
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER).
//
// `hoverTile` is set only for a blueprint holding exactly one building, and is then the
// tile under the cursor. That gesture is a copying tool rather than a layout, so it finds
// its transfer target by hit-testing the cursor instead of by footprint coincidence --
// without which a Shipyard could never be targeted at a different facing, its 4x2
// footprint covering entirely different tiles once rotated. Pass nullopt for a
// constellation, whose ghosts are judged purely by where the blueprint puts them. The
// size is read from the blueprint as stored, 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.
@@ -94,7 +104,7 @@ BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state,
const GameConfig& config,
BuildingType type, QPoint anchor,
Rotation rotation,
bool blueprintHoldsOneBuilding);
std::optional<QPoint> hoverTile);
// What a belt drag would do to one tile of its path (REQ-BLD-BELT-DRAG).
enum class BeltTileAction

View File

@@ -1050,18 +1050,25 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
namespace
{
// A single-building blueprint, whose cursor sits on the ghost's own anchor unless a test
// says otherwise. That gesture hit-tests the cursor for its transfer target.
BlueprintGhostResolved resolveOne(const PlacementFixture& f, BuildingType type,
QPoint anchor, Rotation rotation)
{
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation,
/*blueprintHoldsOneBuilding*/ true);
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, anchor);
}
BlueprintGhostResolved resolveOneHovering(const PlacementFixture& f, BuildingType type,
QPoint anchor, Rotation rotation, QPoint cursorTile)
{
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, cursorTile);
}
// One ghost of a constellation: no cursor hit-test, judged purely on where it sits.
BlueprintGhostResolved resolveInConstellation(const PlacementFixture& f, BuildingType type,
QPoint anchor, Rotation rotation)
{
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation,
/*blueprintHoldsOneBuilding*/ false);
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, std::nullopt);
}
} // namespace
@@ -1148,8 +1155,9 @@ TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its s
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).
// A transfer never rotates anything, so which way the target faces cannot matter
// (REQ-UI-BLUEPRINT-TRANSFER). The ghost snaps to the target's facing rather than
// keeping the blueprint's.
PlacementFixture f;
const BuildingId id =
@@ -1160,6 +1168,47 @@ TEST_CASE("resolveBlueprintGhost: a transfer ignores the target's rotation", "[b
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
REQUIRE(resolved.targetId.has_value());
CHECK(*resolved.targetId == id);
CHECK(resolved.ghostRotation == Rotation::East);
}
TEST_CASE("resolveBlueprintGhost: a single-building blueprint transfers from anywhere on the target",
"[blueprint]")
{
// The point of hit-testing the cursor instead of comparing footprints. Coincidence
// needs the ghost's anchor to land on the target's own anchor, so with a 2x2 body
// three of its four tiles missed and read as an ordinary overlap. Hovering any body
// tile now targets it, and the ghost snaps onto the building
// (REQ-UI-BLUEPRINT-TRANSFER).
PlacementFixture f;
// Assembler body covers (-3,0),(-2,0),(-3,1),(-2,1); its anchor is (-3,0).
const BuildingId id =
f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();
const QPoint offAnchorTile(-2, 1);
const BlueprintGhostResolved resolved =
resolveOneHovering(f, BuildingType::Assembler, offAnchorTile, Rotation::East,
offAnchorTile);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
CHECK(*resolved.targetId == id);
CHECK(resolved.ghostAnchor == QPoint(-3, 0));
// The same misaligned ghost inside a constellation still just overlaps invalidly:
// a layout is placed where the blueprint puts it, and nothing snaps.
CHECK(resolveInConstellation(f, BuildingType::Assembler, offAnchorTile, Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: hovering a different building type does not transfer",
"[blueprint]")
{
PlacementFixture f;
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
// A miner blueprint over a smelter: the cursor hit-test only matches its own type.
CHECK(resolveOne(f, BuildingType::Miner, QPoint(-3, 0), Rotation::East).action
== BlueprintGhostAction::Invalid);
}
TEST_CASE("resolveBlueprintGhost: a construction site is a transfer target too",
@@ -1215,29 +1264,18 @@ TEST_CASE("resolveBlueprintGhost: a constellation transfers onto a matching buil
CHECK(*resolved.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: only a single-building blueprint ignores target rotation",
TEST_CASE("resolveBlueprintGhost: a constellation still requires a matching rotation",
"[blueprint]")
{
// The one thing blueprint size still decides. Inside a constellation a differently
// facing twin cannot be re-oriented, so it blocks the whole placement; alone, the
// gesture transfers anyway because it rotates nothing (REQ-UI-BLUEPRINT-TRANSFER).
//
// A splitter, because the question only arises for a footprint that survives
// rotation. A miner's body is L-shaped ("AA" / "A>"), so a rotated miner ghost covers
// different tiles and coincides with nothing at all -- invalid for a reason that has
// nothing to do with these rules.
// Only the single-building gesture is forgiving about facing. A constellation's
// ghosts stay where the blueprint puts them, and one that cannot be re-oriented to
// match blocks the whole placement (REQ-UI-BLUEPRINT-OVERLAP).
PlacementFixture f;
const BuildingId id =
f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0).value();
f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0);
CHECK(resolveInConstellation(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North).action
== BlueprintGhostAction::Invalid);
const BlueprintGhostResolved alone =
resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North);
REQUIRE(alone.action == BlueprintGhostAction::Transfer);
CHECK(*alone.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain overlaps",

View File

@@ -632,12 +632,16 @@ 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).
// A single-building blueprint hit-tests the cursor for its transfer target; a
// constellation does not (REQ-UI-BLUEPRINT-TRANSFER). `center` is the cursor tile.
// The size is read from the blueprint as stored, before locked types are dropped, so
// the gesture behaves the same however much the player has unlocked.
const std::optional<QPoint> hoverTile =
m_buildMode.getBlueprint().buildings.size() == 1 ? std::make_optional(center)
: std::nullopt;
return resolveBlueprintGhost(m_sim->getFactoryState(), m_sim->getConfig(),
building.type, center + building.offset, building.rotation,
m_buildMode.getBlueprint().buildings.size() == 1);
hoverTile);
}
std::string GameWorldView::unlockedRecipeId(const BlueprintBuilding& building) const

View File

@@ -970,23 +970,27 @@ 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;
// A single-building blueprint hit-tests the cursor for its transfer target; a
// constellation does not (REQ-UI-BLUEPRINT-TRANSFER). The stored building count,
// not the count after locked types are dropped, so the rule does not shift as the
// player unlocks things.
const QPoint cursorTile = frame.buildMode.getBlueprintGhostTile();
const std::optional<QPoint> hoverTile =
frame.buildMode.getBlueprint().buildings.size() == 1
? std::make_optional(cursorTile) : std::nullopt;
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;
// 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.
// compatible overlap is an ordinary valid ghost. The resolved anchor and
// rotation are drawn rather than the blueprint's own, so a hovered transfer
// target shows the ghost snapped onto it.
const BlueprintGhostResolved resolved = resolveBlueprintGhost(
m_sim.getFactoryState(), m_sim.getConfig(), bb.type, anchor, bb.rotation,
holdsOneBuilding);
m_sim.getFactoryState(), m_sim.getConfig(), bb.type, cursorTile + bb.offset,
bb.rotation, hoverTile);
GhostTint tint = GhostTint::Normal;
if (resolved.action == BlueprintGhostAction::Transfer)
{
@@ -996,8 +1000,9 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
{
tint = GhostTint::Invalid;
}
drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation,
tint, /*showPortTargetGlyphs*/ false);
drawBuildingGhost(painter, coordinates, bb.type, resolved.ghostAnchor,
resolved.ghostRotation, tint,
/*showPortTargetGlyphs*/ false);
}
}