4 Commits

17 changed files with 864 additions and 69 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

@@ -127,8 +127,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires deconstructing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement (invalid in normal builder mode; skipped in blueprint placement mode). This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE (builder mode) or REQ-UI-BLUEPRINT-OVERLAP and REQ-UI-BLUEPRINT-TRANSFER (blueprint placement mode), and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires deconstructing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement, which is invalid. This applies **only in normal builder mode**, including the belt drag of REQ-BLD-BELT-DRAG. Blueprint placement mode never rotates an existing building in place: there, a ghost whose footprint coincides with an existing same-type building or site is a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER), or a compatible overlap that is left untouched (REQ-UI-BLUEPRINT-OVERLAP), or else an ordinary occupied-tile overlap, which is invalid.
- REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile.
- **Snapping to a building.** When the tile under the cursor is occupied by a non-belt building or construction site (the **target**), the path does not end on that occupied tile. Instead the end tile is the tile **closest to the cursor** (by distance from the cursor position to the tile) among the tiles orthogonally adjacent to the target across one of its **input-capable edges** — any footprint edge that is not one of the target's output ports, i.e. an edge on which the target can accept an incoming item (REQ-MAT-INPUT-PORTS for buildings, REQ-MAT-ACCEPT-DIR for splitters and tunnels). The geometrically closest such tile is **always** used, even if it turns out not to be a valid belt endpoint — in that case it is previewed and applied by the ordinary rules below (invalid color and skipped if occupied by a non-belt building or invalid terrain; re-oriented if it already holds a belt). The rest of the L-shaped path is computed from the anchor to this end tile exactly as above. The end tile's belt direction points **toward the target** (across the shared input edge), overriding the "final tile keeps its incoming step" rule; this applies whether the end tile is a newly placed belt or an existing belt re-oriented in place, and is reflected both in the ghost preview and in the placement on release.
- **Rotating during the drag.** Rotating the belt with R / Shift+R (REQ-BLD-ROTATE) while a drag is in progress re-picks the path's primary axis immediately from the new orientation and re-derives the whole path from the anchor to the current cursor tile, without waiting for the next cursor movement.
@@ -154,7 +154,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. Confirming a layout identical to the one already configured is not a change and cancels nothing (REQ-MAT-INPUT-BUFFER).
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
@@ -198,7 +198,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- **Scope.** Direct coupling is the only case in which materials move between buildings without a belt, splitter, or tunnel (REQ-MAT-BELT-ONLY); it bridges only two buildings that are directly adjacent with meeting output/input ports. Transport tiles feeding a building (belt, splitter, or tunnel exit) continue to work through the normal pull, and a producer still hands off to a transport tile placed in the gap as before; a single such tile between two buildings is unaffected by this requirement.
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated).
- **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects.
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER.
- REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) the cycle's output must fit in the output buffer. For the Reprocessing Plant, the output is picked at cycle start (weighted pick); the cycle only starts if that chosen output fits. On cycle start, inputs are consumed immediately and the production timer begins. On cycle completion, the (already-decided) output is deposited into the output buffer and the building returns to idle.
- REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles.
@@ -605,9 +606,39 @@ 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. 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: 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. If both conditions are met, a construction site is added to the build queue for each 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.
- 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.
- REQ-UI-BLUEPRINT-OVERLAP: **Compatible overlap.** In blueprint placement mode, a ghost whose footprint **exactly coincides** with the footprint of an existing placed building or construction site that is of the **same building type** and has the **same rotation** is a *compatible overlap*: the building the blueprint wants is already there. Such a ghost is **valid** despite the occupied tiles (REQ-BLD-PLACE-VALID condition (b)), so it does not block the placement of the rest of the constellation — dropping a blueprint over a partially-built copy of itself fills in what is missing. It is drawn in the ordinary per-building ghost color (REQ-BLD-GHOST), like any other valid ghost.
On placement the overlapped building is **left completely untouched**: no construction site is placed on it, no building blocks are charged for it (it is excluded from the total cost of REQ-UI-BLUEPRINT-PLACE), its rotation is not changed, and a construction site's progress is preserved. This applies per building in the blueprint, independently, and to blueprints of any size. Unlike REQ-BLD-ROTATE-IN-PLACE, which it replaces in this mode, it applies to Tunnel Entries and Tunnel Exits too — nothing is re-oriented, so the reason for their exception does not arise.
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 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. 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.
- REQ-UI-BLUEPRINT-TRANSFER: **Configuration transfer.** A blueprint hands its stored settings to buildings that are already standing where it wants them, instead of only to ones it places. A single-building blueprint therefore doubles as a way to copy one building's settings onto others of the same type: select a configured building, press **C** to capture it as a temporary blueprint and enter placement mode (REQ-UI-BLUEPRINT-TEMP), then click same-type buildings to stamp its settings onto them. **V** re-enters that mode later. A multi-building blueprint does the same for each of its buildings as it is placed.
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).
- 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)).
- **Left-clicking transfers the configuration** to the existing building or site, making the target's settings **identical to the source's**: the recipe ID (Miner, Assembler), the schematic ID together with the ship layout (Shipyard), or the two output filters (Splitter). No construction site is placed, no building blocks are consumed (it is excluded from the total cost of REQ-UI-BLUEPRINT-PLACE), and the target's **rotation is not changed** — a transfer never rotates.
- The transfer is a **full mirror, including the absence of a setting**: where the blueprint stores no configuration for a field (REQ-UI-BLUEPRINT-STORAGE stores nothing for an unselected recipe or schematic, and no filter lists for a splitter whose filters were empty at capture time), the target's corresponding setting is **cleared** rather than left as it was. So a splitter captured with no filters clears the target splitter's filters back to accept-all, and a miner captured with no recipe selected clears the target miner's recipe. This holds for every blueprint size, so a constellation captured from unconfigured buildings clears the settings of every matching building it is dropped on.
- The transfer has the same effects as making that selection through the selected building panel, clearing included: buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD. It inherits the no-op rule of REQ-MAT-INPUT-BUFFER with them: a transfer onto a building whose settings already match the source changes nothing at all — no buffers cleared, no production cycle cancelled, no construction progress lost — so repeatedly clicking already-matching buildings is harmless. Each field is judged on its own, so transferring an identical recipe with a differing layout affects only the layout. The layout configuration dialog does not auto-open (REQ-MOD-UI-AUTO-DIALOG).
- Unlock gating matches placement (REQ-UI-BLUEPRINT-PLACE): a stored schematic is applied only if it is currently unlocked, and locked recipe IDs and splitter filter entries for locked item types are handled per REQ-LOCK-UI-BLUEPRINT.
- Both operational buildings and construction sites are transfer targets (REQ-BLD-SITE-CONFIG); a configuration applied to a site carries over unchanged when it finishes building.
- After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn.
- A blueprint placement applies every transfer among its ghosts in the same click that places its new construction sites (REQ-UI-BLUEPRINT-PLACE); the placement is all-or-nothing, so if any ghost is invalid nothing is placed and nothing is transferred.
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint card (REQ-UI-BLUEPRINT-CARD) immediately removes that blueprint from the list, without a confirmation prompt. The blueprint selection dialog stays open and its card grid reflows to close the gap. If the deleted blueprint was active in blueprint placement mode, that mode is exited.

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

@@ -284,6 +284,21 @@ void BuildingSystem::setShipLayout(FactoryState& state, BuildingId id, const Shi
{
if (building.id == id)
{
// No-op if the layout is unchanged, so re-applying the layout a shipyard
// already has does not cancel its production cycle or wipe its buffers
// (REQ-MAT-INPUT-BUFFER, REQ-BLD-SHIPYARD). Confirming the layout dialog
// without editing anything, and a blueprint configuration transfer onto an
// already-matching shipyard (REQ-UI-BLUEPRINT-TRANSFER), both land here.
// An unset layout counts as an empty one: the two are equivalent for
// buffers, production, and the spawned ship (see the spawn path below),
// so an empty layout arriving at an unconfigured shipyard changes nothing.
const bool unchanged = building.shipLayout.has_value()
? *building.shipLayout == layout
: layout.placedModules.empty();
if (unchanged)
{
return;
}
if (building.production.has_value())
{
building.production = std::nullopt;

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,138 @@ 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;
}
namespace
{
// 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,
anchor, rotation};
}
const std::optional<CoincidingBuilding> coinciding =
findCoincidingSameTypeBuilding(state, config, type, anchor, rotation);
if (coinciding.has_value())
{
// 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
// not excluded from the latter: nothing is rotated, so the reason for their
// REQ-BLD-ROTATE-IN-PLACE exception does not arise.
if (coinciding->rotation == rotation)
{
return BlueprintGhostResolved{isConfigurableBuildingType(type)
? BlueprintGhostAction::Transfer
: BlueprintGhostAction::CompatibleOverlap,
coinciding->id, anchor, rotation};
}
// 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,
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,
anchor, rotation};
}
}
return BlueprintGhostResolved{BlueprintGhostAction::PlaceNew, std::nullopt,
anchor, rotation};
}
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,48 @@ 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
// 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).
//
// `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.
BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state,
const GameConfig& config,
BuildingType type, QPoint anchor,
Rotation rotation,
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

@@ -15,8 +15,23 @@ struct PlacedModule
Rotation rotation;
};
inline bool operator==(const PlacedModule& a, const PlacedModule& b)
{
return a.moduleId == b.moduleId && a.position == b.position && a.rotation == b.rotation;
}
// The complete module configuration for a shipyard's current ship (REQ-MOD-CONFIG).
struct ShipLayoutConfig
{
std::vector<PlacedModule> placedModules;
};
// Deliberately order-sensitive: two layouts holding the same modules in a different
// vector order compare unequal. This only decides whether applying a layout is a no-op
// (REQ-MAT-INPUT-BUFFER), so the conservative answer is the safe one -- reporting a
// change that is not one costs a buffer reset, reporting no change when there is one
// would leave the shipyard stale.
inline bool operator==(const ShipLayoutConfig& a, const ShipLayoutConfig& b)
{
return a.placedModules == b.placedModules;
}

View File

@@ -103,14 +103,25 @@ TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0));
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, schematic->id);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, ShipLayoutConfig{});
// A real layout, not an empty one: applying an empty layout to a shipyard that has
// none is a no-op (REQ-MAT-INPUT-BUFFER), so it would leave nothing to read back.
ShipLayoutConfig layout;
PlacedModule placed;
placed.moduleId = "armor_plate";
placed.position = QPoint(0, 0);
placed.rotation = Rotation::East;
layout.placedModules.push_back(placed);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, layout);
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
REQUIRE(config.has_value());
CHECK(config->type == BuildingType::Shipyard);
REQUIRE(config->recipeId.has_value());
CHECK(*config->recipeId == schematic->id);
CHECK(config->shipLayout.has_value());
REQUIRE(config->shipLayout.has_value());
REQUIRE(config->shipLayout->placedModules.size() == 1);
CHECK(config->shipLayout->placedModules[0].moduleId == "armor_plate");
}
TEST_CASE("readBuildingConfig reads a queued construction site", "[blueprint]")

View File

@@ -1039,6 +1039,292 @@ 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
{
// 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, 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, std::nullopt);
}
} // 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]")
{
// 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 =
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);
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",
"[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: a constellation transfers onto a matching building",
"[blueprint]")
{
// Blueprint size does not gate the transfer itself: a configurable building already
// standing where the blueprint wants it, facing the same way, takes its settings
// whatever else the blueprint holds (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 resolved =
resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
CHECK(*resolved.targetId == id);
}
TEST_CASE("resolveBlueprintGhost: a constellation still requires a matching rotation",
"[blueprint]")
{
// 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;
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);
}
TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain overlaps",
"[blueprint]")
{
// One drop can reconfigure some of the buildings already there while leaving others
// alone: the split is by whether the type has settings at all, not by blueprint size
// (REQ-UI-BLUEPRINT-OVERLAP).
PlacementFixture f;
const BuildingId minerId =
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
const BuildingId smelterId =
f.bs.place(f.state, BuildingType::Smelter, QPoint(-5, 0), Rotation::East, 0).value();
const BlueprintGhostResolved miner =
resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(miner.action == BlueprintGhostAction::Transfer);
CHECK(*miner.targetId == minerId);
// A smelter runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand
// over and it is simply left as it is.
const BlueprintGhostResolved smelter =
resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap);
CHECK(*smelter.targetId == smelterId);
}
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

@@ -262,6 +262,123 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
CHECK_FALSE(b2->production.has_value());
}
// Applying a configuration a building already has is a no-op (REQ-MAT-INPUT-BUFFER).
// setRecipe has always worked this way; setShipLayout did not, and wiped the shipyard
// on every re-apply. That matters now that a blueprint configuration transfer
// (REQ-UI-BLUEPRINT-TRANSFER) can be clicked repeatedly onto matching shipyards.
TEST_CASE("Shipyard: re-applying the same layout keeps production and buffers",
"[modules][shipyard]")
{
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
pm.moduleId = "armor_plate";
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
fillMaterials(sim, yardId, *def, layout);
sim.tick();
const Building* before = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(before != nullptr);
REQUIRE(before->production.has_value());
const Tick completesAt = before->production->completesAt;
// A separately built but equal layout: equality is by value, not by identity.
ShipLayoutConfig sameLayout;
sameLayout.placedModules.push_back(pm);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, sameLayout);
const Building* after = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(after != nullptr);
REQUIRE(after->production.has_value());
CHECK(after->production->completesAt == completesAt);
CHECK(after->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10);
}
TEST_CASE("Shipyard: an empty layout on an unconfigured shipyard changes nothing",
"[modules][shipyard]")
{
// An unset layout and an empty one are the same state everywhere that matters, so
// clearing an already-unconfigured shipyard must not cancel its cycle. This is the
// case a transfer from a layout-less source shipyard produces.
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
ShipLayoutConfig emptyLayout;
fillMaterials(sim, yardId, *def, emptyLayout);
sim.tick();
const Building* before = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(before != nullptr);
REQUIRE(before->production.has_value());
REQUIRE_FALSE(before->shipLayout.has_value());
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, emptyLayout);
const Building* after = findBuilding(sim.getFactoryState(), yardId);
REQUIRE(after != nullptr);
CHECK(after->production.has_value());
CHECK_FALSE(after->shipLayout.has_value());
}
TEST_CASE("Shipyard: the same modules in a different order count as a change",
"[modules][shipyard]")
{
// Layout equality is deliberately order-sensitive (see ShipLayout.h): the safe
// answer when in doubt is "changed", which costs a buffer reset rather than
// leaving a shipyard building the wrong ship.
Simulation sim(loadTestConfig(), 42);
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
REQUIRE(def != nullptr);
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
PlacedModule armor;
armor.moduleId = "armor_plate";
armor.position = QPoint(0, 0);
armor.rotation = Rotation::East;
PlacedModule sensor;
sensor.moduleId = "sensor_booster";
sensor.position = QPoint(1, 0);
sensor.rotation = Rotation::East;
ShipLayoutConfig layout;
layout.placedModules.push_back(armor);
layout.placedModules.push_back(sensor);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
fillMaterials(sim, yardId, *def, layout);
sim.tick();
REQUIRE(findBuilding(sim.getFactoryState(), yardId)->production.has_value());
ShipLayoutConfig reordered;
reordered.placedModules.push_back(sensor);
reordered.placedModules.push_back(armor);
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, reordered);
CHECK_FALSE(findBuilding(sim.getFactoryState(), yardId)->production.has_value());
}
TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
"[modules][shipyard]")
{

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,97 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
}
}
BlueprintGhostResolved GameWorldView::resolveBlueprintGhostHere(const BlueprintBuilding& building,
QPoint center) const
{
// 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,
hoverTile);
}
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,39 @@ void WorldRenderer::drawOverlays(QPainter& painter, const WorldCoordinates& coor
// Blueprint placement ghost
if (frame.buildMode.isBlueprintMode())
{
// 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;
const bool valid = canPlaceBuilding(m_sim.getFactoryState(), m_sim.getConfig(), bb.type, anchor, bb.rotation);
drawBuildingGhost(painter, coordinates, bb.type, anchor, bb.rotation,
valid, /*showPortTargetGlyphs*/ false);
// 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. 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, cursorTile + bb.offset,
bb.rotation, hoverTile);
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, resolved.ghostAnchor,
resolved.ghostRotation, tint,
/*showPortTargetGlyphs*/ false);
}
}
@@ -1048,7 +1074,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 +1084,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