move blueprints out of the sidebar into Ctrl+C / Ctrl+V dialogs

This commit is contained in:
2026-08-06 19:03:59 +02:00
parent 2cbcf1554f
commit 18cfe238f6
22 changed files with 1021 additions and 400 deletions

View File

@@ -433,7 +433,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
### Layout
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) containing the two UI panels stacked vertically. The build button bar (REQ-UI-BUILD-BAR) is not part of either column — it floats over the game world at its bottom center:
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) holding the selected building panel. The build button bar (REQ-UI-BUILD-BAR) is not part of either column — it floats over the game world at its bottom center. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
```
+--------------------------------------+--------------+
@@ -442,10 +442,10 @@ The screen is divided into two columns: a main column (75% width) containing the
| | Building |
| | Panel |
| Game World | |
| +--------------+
| | |
| +------------------+ | Blueprint |
| | Build Button Bar | | Panel |
| | |
| +------------------+ | |
| | Build Button Bar | | |
+--------+------------------+----------+--------------+
(75% width) (25% width)
```
@@ -460,8 +460,8 @@ The screen is divided into two columns: a main column (75% width) containing the
- REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into two equal-height panels stacked top to bottom: selected building panel (top) and blueprint panel (bottom). The build buttons are not part of this column; they float over the game world (REQ-UI-BUILD-BAR).
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It holds a single panel filling that full height: the selected building panel. The build buttons are not part of this column; they float over the game world (REQ-UI-BUILD-BAR). Blueprints are not part of this column either; they are reached through the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG).
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
### Game World
@@ -496,7 +496,9 @@ The screen is divided into two columns: a main column (75% width) containing the
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles deconstruct mode: it enters deconstruct mode if inactive, or exits deconstruct mode if already active. (See also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button.)
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
- **T** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP).
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU).
- **Ctrl+C** — save the current selection as a named blueprint: opens the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
- **Ctrl+V** — opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), from which a saved blueprint is picked for placement. It is available whenever the game is being played, regardless of the current selection or of which build mode is active, and opens the dialog even when no blueprints are saved yet.
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU). While a blueprint dialog is open, Escape closes that dialog instead (REQ-UI-BLUEPRINT-DIALOG).
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
@@ -527,7 +529,7 @@ The screen is divided into two columns: a main column (75% width) containing the
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-CARD). Construction sites count at their building type's full placement cost regardless of construction progress.
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
@@ -563,23 +565,52 @@ The screen is divided into two columns: a main column (75% width) containing the
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
### Blueprint Panel
### Blueprints
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is the lower of the two side panel column panels, below the selected building panel (REQ-UI-PANEL-COLUMN). It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from the current selection (REQ-UI-BLUEPRINT-CREATE) and picked for placement from the blueprint selection dialog, opened with **Ctrl+V** (REQ-UI-BLUEPRINT-DIALOG). There is no blueprint panel in the side panel column (REQ-UI-PANEL-COLUMN). (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.)
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
- REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). The dialog prompts the player to enter a name and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape, or closing the dialog — closes it with no effect and does not open the blueprint selection dialog. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others.
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint panel (REQ-UI-BLUEPRINT-PANEL), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
- REQ-UI-BLUEPRINT-DIALOG: The **blueprint selection dialog** is the only place saved blueprints are shown. It is opened by pressing **Ctrl+V** (REQ-UI-HOTKEYS) and by confirming a save (REQ-UI-BLUEPRINT-CREATE). It is modal, pauses the simulation, and dims the game window (REQ-UI-MODAL-DIM). The dialog has a fixed size and consists of:
- A **title bar** reading `Blueprints`, followed by a small dimmed **hotkey badge** reading `Ctrl+V` — the same "learn the shortcut from the widget" device as the build button badges (REQ-UI-BUILD-COST) — and, at the far right, a **close ("×") button**.
- Below it, a **scrollable two-column grid of blueprint cards** (REQ-UI-BLUEPRINT-CARD), one per saved blueprint, filling the grid left to right and top to bottom in creation order. The column count is fixed at two; the grid scrolls vertically when the cards do not fit, and does not scroll horizontally.
- When no blueprints are saved, the dialog still opens and shows an empty-state message in place of the grid, telling the player that blueprints are created with Ctrl+C from a selection of buildings.
Clicking the close button, pressing Escape, or closing the dialog through the window manager closes it with no other effect: the current selection, build mode, and blueprint list are unchanged, and the simulation speed is restored to what it was before the dialog was opened. While the dialog is open, Escape closes it rather than opening the escape menu (REQ-UI-GAME-MENU).
```
+------------------------------------------------------+
| Blueprints [Ctrl+V] [x] |
+------------------------------------------------------+
| +---------------------+ +---------------------+ |^| |
| | Smelter array | | Gear cell | | | |
| | 4 Smelters, 1 Miner | | 2 Assemblers, ... | | | |
| | 528 [blk] (x) | | 460 [blk] (x) | | | |
| +---------------------+ +---------------------+ | | |
| +---------------------+ | | |
| | Defence line | |_v_|
+------------------------------------------------------+
```
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. A source building may be either an operational building or a construction site (REQ-BLD-SITE-CONFIG); a construction site is captured identically, storing whatever configuration it currently holds and never any buffer or construction-progress state. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
- REQ-UI-BLUEPRINT-BUTTON: Each blueprint entry consists of a blueprint button and a dedicated delete icon ("×") placed to the right of the button. The blueprint button displays the blueprint name and, below it, the total building block cost of the blueprint (sum of the individual costs of all constituent buildings). A blueprint button is disabled when the player cannot afford the total cost. Clicking an enabled blueprint button enters blueprint placement mode for that blueprint. The delete icon is always enabled regardless of whether the player can afford the blueprint.
- REQ-UI-BLUEPRINT-CARD: Each blueprint in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) is shown as a **card**. All cards share a uniform size. A card contains, from top to bottom:
- The **blueprint name**.
- A **contents line** summarizing what the blueprint holds: one `<building name> x <count>` entry per building type it contains, comma-separated, in descending count order with ties broken by the order the types appear in the build button bar (REQ-UI-BUILD-BAR). This is the same `x`-count notation as the building multi-selection summary (REQ-UI-MULTI-SELECTION). If the entries do not fit on the line, the line is elided at its end rather than wrapped or shrunk, so every card keeps the same height.
- The **total building block cost** of the blueprint (sum of the individual costs of all constituent buildings), shown with the `building_block` item icon to the right of the number in place of a trailing `Blocks` word, exactly as elsewhere in the UI (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST). When no icon file exists for `building_block`, the cost is shown as the bare number.
- A **delete icon ("×")** in the card's bottom-right corner (REQ-UI-BLUEPRINT-DELETE).
- 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. Clicking a different blueprint button exits the current mode and enters blueprint placement mode for the newly clicked blueprint.
A card shows no preview of the blueprint's layout.
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-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-DELETE: Clicking the delete icon ("×") on a blueprint entry immediately removes that blueprint from the list. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
- 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.
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).

View File

@@ -0,0 +1,11 @@
#pragma once
#include "Event.h"
// Ctrl+C: save the current building selection as a named blueprint
// (REQ-UI-BLUEPRINT-CREATE, REQ-UI-HOTKEYS). MainWindow owns the modal flow, because
// it is the only widget that can pause the game and raise the dim overlay; whether
// anything placeable is selected is decided there, not by the key handler.
class BlueprintSaveRequestedEvent : public Event
{
};

View File

@@ -0,0 +1,11 @@
#pragma once
#include "Event.h"
// Ctrl+V: open the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG,
// REQ-UI-HOTKEYS). The Ctrl+C path does not go through this event -- it opens the
// dialog directly so one pause scope and one dim scope span both dialogs
// (REQ-UI-MODAL-DIM).
class BlueprintSelectionRequestedEvent : public Event
{
};

View File

@@ -15,6 +15,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSaveRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h

View File

@@ -3,10 +3,14 @@
#include <algorithm>
#include <climits>
#include <cstddef>
#include <map>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingsConfig.h"
#include "BuildingSystem.h"
#include "DisplayName.h"
#include "Simulation.h"
namespace
@@ -49,6 +53,21 @@ std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, Building
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
return resolved;
}
// Position of a building type in buildings.toml, which is the order the build button
// bar lays out its buttons (REQ-UI-BUILD-BAR) and so the tie-break order for a
// blueprint's contents line. A type with no config entry sorts after every known one.
std::size_t configOrderIndex(BuildingType type, const BuildingsConfig& buildings)
{
for (std::size_t i = 0; i < buildings.buildings.size(); ++i)
{
if (buildings.buildings[i].type == type)
{
return i;
}
}
return buildings.buildings.size();
}
} // namespace
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
@@ -166,3 +185,56 @@ bool selectionHasPlaceableBuilding(const Simulation& sim,
}
return false;
}
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
const Blueprint& blueprint, const BuildingsConfig& buildings)
{
std::map<BuildingType, int> counts;
for (const BlueprintBuilding& building : blueprint.buildings)
{
++counts[building.type];
}
// The config index is carried through the sort so the comparator stays a strict
// total order; the enum value is the final tie-break, which only two config-less
// types could ever reach.
struct RankedType
{
BuildingType type;
int count;
std::size_t order;
};
std::vector<RankedType> ranked;
ranked.reserve(counts.size());
for (const std::pair<const BuildingType, int>& entry : counts)
{
ranked.push_back({entry.first, entry.second,
configOrderIndex(entry.first, buildings)});
}
std::sort(ranked.begin(), ranked.end(),
[](const RankedType& left, const RankedType& right)
{
if (left.count != right.count) { return left.count > right.count; }
if (left.order != right.order) { return left.order < right.order; }
return static_cast<int>(left.type) < static_cast<int>(right.type);
});
std::vector<BlueprintContentEntry> summary;
summary.reserve(ranked.size());
for (const RankedType& entry : ranked)
{
summary.push_back({toDisplayName(buildingTypeId(entry.type)), entry.count});
}
return summary;
}
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings)
{
int total = 0;
for (const BlueprintBuilding& building : blueprint.buildings)
{
const BuildingDef* def = buildings.findBuildingDef(building.type);
if (def) { total += def->cost; }
}
return total;
}

View File

@@ -11,6 +11,7 @@
#include "ShipLayout.h"
class Simulation;
struct BuildingsConfig;
// The user-configurable settings of a single building or construction site: the
// selected recipe / ship schematic, the shipyard module layout, and (for
@@ -49,6 +50,29 @@ Blueprint captureBlueprintFromSelection(const Simulation& sim,
const std::vector<BuildingId>& selectedIds);
// True if any selected id refers to a player-placeable building or construction site
// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE).
// (the condition under which Ctrl+C opens the blueprint save dialog,
// REQ-UI-BLUEPRINT-CREATE).
bool selectionHasPlaceableBuilding(const Simulation& sim,
const std::vector<BuildingId>& selectedIds);
// One "<building name> x <count>" entry of a blueprint card's contents line
// (REQ-UI-BLUEPRINT-CARD).
struct BlueprintContentEntry
{
std::string buildingName;
int count;
};
// Summarizes what a blueprint holds: one entry per building type it contains, ordered
// by descending count with ties broken by the order the types appear in buildings.toml
// (which is the order of the build button bar, REQ-UI-BUILD-BAR). Building types absent
// from the config sort last. Derived display data for the blueprint card, kept here so
// it is unit-testable rather than buried in the dialog (REQ-UI-BLUEPRINT-CARD).
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
const Blueprint& blueprint, const BuildingsConfig& buildings);
// Plain sum of the placement cost of every building in the blueprint
// (REQ-UI-BLUEPRINT-CARD). Distinct from the total charged on placement
// (REQ-UI-BLUEPRINT-PLACE), which additionally excludes locked building types and
// rotate-in-place targets; see GameWorldView's placement path.
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings);

View File

@@ -56,7 +56,7 @@ static Rotation rotCCW(Rotation r)
return Rotation::East;
}
// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building
// Mirror of BlueprintLibrary::createBlueprintFromSelection: given per-building
// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division
// bounding-box center and per-building tile offsets.
struct BuildingSpec
@@ -149,7 +149,7 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
}
}
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
// Mirrors BlueprintLibrary::createBlueprintFromSelection's player-placeable filter:
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
// are silently excluded before the bounding-box center and offsets are computed.
static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs,

View File

@@ -82,7 +82,7 @@ TEST_CASE("Only one mode is active at a time", "[buildmode]")
TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]")
{
// Regression: entering builder mode used to drop the blueprint silently, so the
// blueprint panel kept its button highlighted for a mode that was over.
// blueprint library kept tracking an active blueprint for a mode that was over.
BuildModeController controller;
controller.enterBlueprintMode(makeBlueprint());

View File

@@ -139,3 +139,100 @@ TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]"
Simulation sim(loadTestConfig(), 7);
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
}
// The derived facts a blueprint card shows (REQ-UI-BLUEPRINT-CARD). They live here
// rather than in the dialog so they can be tested: src/ui is off the test include path.
namespace
{
Blueprint makeBlueprint(const std::vector<BuildingType>& types)
{
Blueprint blueprint;
for (const BuildingType type : types)
{
BlueprintBuilding building;
building.type = type;
building.rotation = Rotation::East;
blueprint.buildings.push_back(building);
}
return blueprint;
}
} // namespace
TEST_CASE("computeBlueprintCost sums the constituent placement costs", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
// belt 2 + belt 2 + miner 15.
const Blueprint blueprint = makeBlueprint(
{BuildingType::Belt, BuildingType::Belt, BuildingType::Miner});
CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 19);
}
TEST_CASE("computeBlueprintCost ignores types absent from buildings.toml", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
REQUIRE(cfg.buildings.findBuildingDef(BuildingType::Hq) == nullptr);
const Blueprint blueprint = makeBlueprint({BuildingType::Belt, BuildingType::Hq});
CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 2);
}
TEST_CASE("computeBlueprintCost is zero for an empty blueprint", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
CHECK(computeBlueprintCost(Blueprint{}, cfg.buildings) == 0);
}
TEST_CASE("summarizeBlueprintContents orders by descending count", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
const Blueprint blueprint = makeBlueprint(
{BuildingType::Belt, BuildingType::Smelter, BuildingType::Miner,
BuildingType::Miner, BuildingType::Smelter, BuildingType::Miner});
const std::vector<BlueprintContentEntry> summary =
summarizeBlueprintContents(blueprint, cfg.buildings);
REQUIRE(summary.size() == 3);
CHECK(summary[0].buildingName == "Miner");
CHECK(summary[0].count == 3);
CHECK(summary[1].buildingName == "Smelter");
CHECK(summary[1].count == 2);
CHECK(summary[2].buildingName == "Belt");
CHECK(summary[2].count == 1);
}
TEST_CASE("summarizeBlueprintContents breaks ties in buildings.toml order", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
// Miner is declared after Belt in buildings.toml, which is the order the build
// button bar lays its buttons out, so Belt wins the tie despite the equal counts
// and despite sorting later by enum value.
const Blueprint blueprint = makeBlueprint(
{BuildingType::Miner, BuildingType::Belt, BuildingType::Miner, BuildingType::Belt});
const std::vector<BlueprintContentEntry> summary =
summarizeBlueprintContents(blueprint, cfg.buildings);
REQUIRE(summary.size() == 2);
CHECK(summary[0].buildingName == "Belt");
CHECK(summary[1].buildingName == "Miner");
}
TEST_CASE("summarizeBlueprintContents spells multi-word ids as display names", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
const Blueprint blueprint = makeBlueprint({BuildingType::TunnelEntry});
const std::vector<BlueprintContentEntry> summary =
summarizeBlueprintContents(blueprint, cfg.buildings);
REQUIRE(summary.size() == 1);
CHECK(summary[0].buildingName == "Tunnel Entry");
}
TEST_CASE("summarizeBlueprintContents is empty for an empty blueprint", "[blueprint]")
{
const GameConfig cfg = loadTestConfig();
CHECK(summarizeBlueprintContents(Blueprint{}, cfg.buildings).empty());
}

175
src/ui/BlueprintLibrary.cpp Normal file
View File

@@ -0,0 +1,175 @@
#include "BlueprintLibrary.h"
#include <cstddef>
#include <utility>
#include <QCoreApplication>
#include <QFile>
#include <QMessageBox>
#include <QObject>
#include <QStringList>
#include "BlueprintPlacementRequestedEvent.h"
#include "BlueprintSerializer.h"
#include "EventManager.h"
#include "ExitBlueprintModeRequestedEvent.h"
#include "BuildingConfig.h"
#include "Simulation.h"
BlueprintLibrary::BlueprintLibrary(Simulation* sim, const GameConfig* config,
QWidget* dialogParent)
: m_sim(sim)
, m_config(config)
, m_dialogParent(dialogParent)
{
loadFromDisk();
registerForEvents();
}
BlueprintLibrary::~BlueprintLibrary()
{
saveToDisk();
unregisterForEvents();
}
bool BlueprintLibrary::getCanCaptureSelection() const
{
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
}
void BlueprintLibrary::saveSelectionAs(const QString& name)
{
Blueprint blueprint = createBlueprintFromSelection();
if (blueprint.buildings.empty()) { return; }
blueprint.name = name;
m_blueprints.push_back(std::move(blueprint));
}
const std::vector<Blueprint>& BlueprintLibrary::getBlueprints() const
{
return m_blueprints;
}
QString BlueprintLibrary::getContentsSummary(int index) const
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return QString(); }
QStringList entries;
for (const BlueprintContentEntry& entry : summarizeBlueprintContents(
m_blueprints[static_cast<std::size_t>(index)], m_config->buildings))
{
// The same "<type> x <count>" notation the building multi-selection summary
// uses (REQ-UI-MULTI-SELECTION), which also sidesteps plural forms.
entries << QObject::tr("%1 x %2")
.arg(QString::fromStdString(entry.buildingName))
.arg(entry.count);
}
return entries.join(QStringLiteral(", "));
}
int BlueprintLibrary::getCost(int index) const
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return 0; }
return computeBlueprintCost(m_blueprints[static_cast<std::size_t>(index)],
m_config->buildings);
}
bool BlueprintLibrary::getCanAfford(int index) const
{
return m_sim->getBuildingBlocksStock() >= getCost(index);
}
void BlueprintLibrary::remove(int index)
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
if (m_activeIndex == index)
{
m_activeIndex = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
}
else if (m_activeIndex.has_value() && *m_activeIndex > index)
{
--*m_activeIndex;
}
m_blueprints.erase(m_blueprints.begin() + index);
}
void BlueprintLibrary::beginPlacement(int index)
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
m_activeIndex = index;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(
m_blueprints[static_cast<std::size_t>(index)]));
}
void BlueprintLibrary::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
m_selectedBuildingIds = event->ids;
}
void BlueprintLibrary::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
{
m_activeIndex = std::nullopt;
}
void BlueprintLibrary::handleEvent(
std::shared_ptr<const TemporaryBlueprintRequestedEvent> /*event*/)
{
// Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and
// enter placement mode without adding it to the list or persisting it. If nothing
// player-placeable is selected, do nothing.
Blueprint blueprint = createBlueprintFromSelection();
if (blueprint.buildings.empty()) { return; }
// No saved blueprint is active while a temporary one is being placed.
m_activeIndex = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(std::move(blueprint)));
}
Blueprint BlueprintLibrary::createBlueprintFromSelection() const
{
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE).
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
}
void BlueprintLibrary::loadFromDisk()
{
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
try
{
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
}
catch (const std::exception& e)
{
QMessageBox::critical(m_dialogParent, QObject::tr("Load Failed"),
QObject::tr("Failed to load blueprints:\n%1").arg(e.what()));
m_blueprints.clear();
}
}
void BlueprintLibrary::saveToDisk() const
{
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
try
{
const std::string content = BlueprintSerializer::serialize(m_blueprints);
file.write(QByteArray::fromStdString(content));
}
catch (...) {}
}

86
src/ui/BlueprintLibrary.h Normal file
View File

@@ -0,0 +1,86 @@
#pragma once
#include <optional>
#include <vector>
#include <QString>
#include "Blueprint.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingId.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "SelectionChangedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
class Simulation;
class QWidget;
// The player's saved blueprints: the list itself, its persistence (REQ-UI-BLUEPRINT-SAVE,
// REQ-UI-BLUEPRINT-LOAD), capture from the current selection (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-TEMP), and entry into blueprint placement mode.
//
// Deliberately not a widget: blueprints have no permanent place on screen any more
// (REQ-UI-BLUEPRINT-DIALOG), and the modal dialogs that present them must be driven from
// MainWindow, the only widget that can pause the game (ModalPauseScope) and raise the dim
// overlay. This class is the model those dialogs read and mutate.
class BlueprintLibrary : public CombinedEventHandler<SelectionChangedEvent,
BlueprintModeExitedEvent,
TemporaryBlueprintRequestedEvent>
{
public:
// dialogParent parents the load-failure message box (REQ-UI-BLUEPRINT-LOAD) and is
// not owned.
BlueprintLibrary(Simulation* sim, const GameConfig* config, QWidget* dialogParent);
~BlueprintLibrary();
// True when the current selection holds at least one player-placeable building or
// construction site -- the condition under which Ctrl+C has any effect
// (REQ-UI-BLUEPRINT-CREATE).
bool getCanCaptureSelection() const;
// Captures the current selection under the given name and appends it to the list.
// Silently does nothing when nothing player-placeable is selected.
void saveSelectionAs(const QString& name);
const std::vector<Blueprint>& getBlueprints() const;
// The blueprint's contents line, ready for its card: one "<name> x <count>" entry
// per building type, comma-separated, ordered by summarizeBlueprintContents
// (REQ-UI-BLUEPRINT-CARD).
QString getContentsSummary(int index) const;
// Sum of the constituent buildings' placement costs (REQ-UI-BLUEPRINT-CARD).
int getCost(int index) const;
// Whether the player can currently afford the blueprint's total cost, which is what
// enables or greys out its card (REQ-UI-BLUEPRINT-CARD).
bool getCanAfford(int index) const;
// Removes the blueprint, exiting blueprint placement mode if it was the active one
// (REQ-UI-BLUEPRINT-DELETE).
void remove(int index);
// Enters blueprint placement mode for the blueprint (REQ-UI-BLUEPRINT-MODE).
void beginPlacement(int index);
private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> event) override;
Blueprint createBlueprintFromSelection() const;
void loadFromDisk();
void saveToDisk() const;
// The simulation is the single source of truth for the block stock and the
// selection; the change events are only refresh signals.
Simulation* m_sim;
const GameConfig* m_config;
QWidget* m_dialogParent;
std::vector<BuildingId> m_selectedBuildingIds;
std::vector<Blueprint> m_blueprints;
// Index of the blueprint currently in placement mode, so deleting it can exit that
// mode (REQ-UI-BLUEPRINT-DELETE). nullopt = no saved blueprint is being placed.
std::optional<int> m_activeIndex;
};

View File

@@ -1,281 +0,0 @@
#include "BlueprintPanel.h"
#include <algorithm>
#include <climits>
#include <QCoreApplication>
#include <QFile>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
#include "BlueprintPlacementRequestedEvent.h"
#include "BlueprintSerializer.h"
#include "BuildingBlocksChangedEvent.h"
#include "EventManager.h"
#include "ExitBlueprintModeRequestedEvent.h"
#include "Building.h"
#include "BuildingConfig.h"
#include "BuildingSystem.h"
#include "Simulation.h"
BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(4);
m_createBtn = new QPushButton(tr("Create Blueprint"), this);
m_createBtn->setFixedHeight(48);
m_createBtn->setEnabled(false);
layout->addWidget(m_createBtn);
QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_buttonsContainer = new QWidget(scrollArea);
m_buttonsLayout = new QVBoxLayout(m_buttonsContainer);
m_buttonsLayout->setContentsMargins(0, 0, 0, 0);
m_buttonsLayout->setSpacing(4);
m_buttonsLayout->addStretch();
scrollArea->setWidget(m_buttonsContainer);
layout->addWidget(scrollArea, 1);
connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked);
loadFromDisk();
rebuildButtons();
registerForEvents();
}
BlueprintPanel::~BlueprintPanel()
{
saveToDisk();
unregisterForEvents();
}
void BlueprintPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
{
m_selectedBuildingIds = ids;
refreshButtonStates();
}
void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
{
refreshButtonStates();
}
void BlueprintPanel::clearActiveBlueprintButton()
{
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
}
m_activeIndex = std::nullopt;
refreshButtonStates();
}
void BlueprintPanel::onCreateClicked()
{
if (m_selectedBuildingIds.empty()) { return; }
Blueprint bp = createBlueprintFromSelection();
if (bp.buildings.empty()) { return; }
bool ok = false;
const QString name = QInputDialog::getText(
this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal, QString(), &ok);
if (!ok || name.trimmed().isEmpty()) { return; }
bp.name = name.trimmed();
m_blueprints.push_back(bp);
rebuildButtons();
}
void BlueprintPanel::onDeleteBlueprintClicked(int index)
{
if (m_activeIndex == index)
{
m_activeIndex = std::nullopt;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
}
else if (m_activeIndex.has_value() && *m_activeIndex > index)
{
--*m_activeIndex;
}
m_blueprints.erase(m_blueprints.begin() + index);
rebuildButtons();
}
void BlueprintPanel::onBlueprintButtonClicked(int index)
{
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
if (m_activeIndex == index)
{
clearActiveBlueprintButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ExitBlueprintModeRequestedEvent>());
return;
}
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
{
m_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
}
m_activeIndex = index;
m_blueprintButtons[static_cast<std::size_t>(index)]->setChecked(true);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(m_blueprints[static_cast<std::size_t>(index)]));
}
Blueprint BlueprintPanel::createBlueprintFromSelection() const
{
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
// REQ-UI-BLUEPRINT-STORAGE).
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
}
int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
{
int total = 0;
for (const BlueprintBuilding& bb : bp.buildings)
{
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
if (def) { total += def->cost; }
}
return total;
}
void BlueprintPanel::rebuildButtons()
{
while (m_buttonsLayout->count() > 1)
{
QLayoutItem* item = m_buttonsLayout->takeAt(0);
if (item->widget()) { delete item->widget(); }
delete item;
}
m_blueprintButtons.clear();
for (int i = 0; i < static_cast<int>(m_blueprints.size()); ++i)
{
const Blueprint& bp = m_blueprints[static_cast<std::size_t>(i)];
const int cost = computeBlueprintCost(bp);
const QString label = bp.name + "\n" + tr("%1 Building Blocks").arg(cost);
QWidget* row = new QWidget(m_buttonsContainer);
QHBoxLayout* rowLayout = new QHBoxLayout(row);
rowLayout->setContentsMargins(0, 0, 0, 0);
rowLayout->setSpacing(4);
QPushButton* btn = new QPushButton(label, row);
btn->setCheckable(true);
btn->setFixedHeight(48);
QPushButton* delBtn = new QPushButton("\xc3\x97", row);
delBtn->setFixedWidth(28);
delBtn->setFixedHeight(48);
rowLayout->addWidget(btn, 1);
rowLayout->addWidget(delBtn, 0);
m_buttonsLayout->insertWidget(i, row);
const int capturedIndex = i;
connect(btn, &QPushButton::clicked, this, [this, capturedIndex]() {
onBlueprintButtonClicked(capturedIndex);
});
connect(delBtn, &QPushButton::clicked, this, [this, capturedIndex]() {
onDeleteBlueprintClicked(capturedIndex);
});
m_blueprintButtons.push_back(btn);
}
refreshButtonStates();
}
void BlueprintPanel::saveToDisk() const
{
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
try
{
const std::string content = BlueprintSerializer::serialize(m_blueprints);
file.write(QByteArray::fromStdString(content));
}
catch (...) {}
}
void BlueprintPanel::loadFromDisk()
{
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
try
{
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
}
catch (const std::exception& e)
{
QMessageBox::critical(this, tr("Load Failed"),
tr("Failed to load blueprints:\n%1").arg(e.what()));
m_blueprints.clear();
}
}
void BlueprintPanel::refreshButtonStates()
{
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds));
const int blocks = m_sim->getBuildingBlocksStock();
for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i)
{
const int cost = computeBlueprintCost(m_blueprints[static_cast<std::size_t>(i)]);
const bool canAfford = blocks >= cost;
m_blueprintButtons[static_cast<std::size_t>(i)]->setEnabled(
canAfford || m_activeIndex == i);
}
}
void BlueprintPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
onSelectionChanged(event->ids);
}
void BlueprintPanel::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
{
clearActiveBlueprintButton();
}
void BlueprintPanel::handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> /*event*/)
{
// Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and
// enter placement mode without adding it to the list or persisting it. If nothing
// player-placeable is selected, do nothing.
Blueprint bp = createBlueprintFromSelection();
if (bp.buildings.empty()) { return; }
// No saved blueprint is active while a temporary one is being placed.
clearActiveBlueprintButton();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintPlacementRequestedEvent>(std::move(bp)));
}

View File

@@ -1,67 +0,0 @@
#pragma once
#include <optional>
#include <vector>
#include <QWidget>
#include "Blueprint.h"
#include "BlueprintModeExitedEvent.h"
#include "BuildingBlocksChangedEvent.h"
#include "BuildingId.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "SelectionChangedEvent.h"
#include "TemporaryBlueprintRequestedEvent.h"
#include "Tick.h"
class Simulation;
class QPushButton;
class QScrollArea;
class QVBoxLayout;
class BlueprintPanel : public QWidget,
public CombinedEventHandler<BuildingBlocksChangedEvent,
SelectionChangedEvent,
BlueprintModeExitedEvent,
TemporaryBlueprintRequestedEvent>
{
Q_OBJECT
public:
BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
~BlueprintPanel() override;
private:
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> event) override;
private slots:
void onCreateClicked();
void onDeleteBlueprintClicked(int index);
void onBlueprintButtonClicked(int index);
private:
void onSelectionChanged(const std::vector<BuildingId>& ids);
void clearActiveBlueprintButton();
Blueprint createBlueprintFromSelection() const;
int computeBlueprintCost(const Blueprint& bp) const;
void rebuildButtons();
void refreshButtonStates();
void loadFromDisk();
void saveToDisk() const;
// The simulation is the single source of truth for the block stock; the
// change event is only a refresh signal.
Simulation* m_sim;
const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds;
std::optional<int> m_activeIndex; // nullopt = no blueprint selected
std::vector<Blueprint> m_blueprints;
std::vector<QPushButton*> m_blueprintButtons;
QPushButton* m_createBtn;
QWidget* m_buttonsContainer;
QVBoxLayout* m_buttonsLayout;
};

View File

@@ -0,0 +1,333 @@
#include "BlueprintSelectionDialog.h"
#include <cstddef>
#include <vector>
#include <QChar>
#include <QColor>
#include <QFont>
#include <QFontMetrics>
#include <QFrame>
#include <QGridLayout>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QIcon>
#include <QLabel>
#include <QLayoutItem>
#include <QPainter>
#include <QPalette>
#include <QPixmap>
#include <QPoint>
#include <QPushButton>
#include <QRect>
#include <QScrollArea>
#include <QSize>
#include <QString>
#include <QStyle>
#include <QVBoxLayout>
#include "Blueprint.h"
#include "BlueprintLibrary.h"
#include "IconCaption.h"
#include "ItemIconCache.h"
namespace
{
// Fixed at two, per REQ-UI-BLUEPRINT-DIALOG; the grid scrolls rather than reflowing.
const int kGridColumnCount = 2;
// Card geometry. Every card is the same size so the grid stays even
// (REQ-UI-BLUEPRINT-CARD); the width is chosen to fit a name and a short contents
// line, and the height follows from three text rows.
const int kCardWidthPx = 220;
const int kCardPaddingPx = 10;
const int kCardRowGapPx = 4;
// Side length of the per-card delete icon and of the dialog's close button.
const int kSmallButtonSizePx = 22;
// Spacing between cards, and the dialog's own content margin.
const int kSpacingPx = 8;
// Card rows visible before the grid scrolls. The half row is deliberate: a cut-off
// card reads as "there is more below" at a glance.
const double kVisibleRowCount = 2.5;
// U+00D7 MULTIPLICATION SIGN, the close and delete glyph. Written as a code point
// because the sources are not guaranteed to be read as UTF-8 by every compiler.
const QChar kCrossGlyph(0x00D7);
// Inner size of a card's composed face: the name row, the contents row, and the
// cost row, plus the gap above the cost.
QSize getCardFaceSize(const QFont& font)
{
const int rowHeight = QFontMetrics(font).height();
return QSize(kCardWidthPx - 2 * kCardPaddingPx, rowHeight * 3 + kCardRowGapPx);
}
QSize getCardSize(const QFont& font)
{
const QSize faceSize = getCardFaceSize(font);
return QSize(faceSize.width() + 2 * kCardPaddingPx,
faceSize.height() + 2 * kCardPaddingPx);
}
// One card face: the blueprint name, its contents line below in a dimmed color,
// and the cost with its block icon at the bottom (REQ-UI-BLUEPRINT-CARD). The three
// are composed into a single pixmap because a QPushButton holds only one icon --
// the same device the build button faces use.
QPixmap composeCardFace(const QString& name, const QString& contents,
const QPixmap& cost, const QFont& font,
const QColor& textColor, const QColor& dimColor,
const QSize& faceSize)
{
const QFontMetrics metrics(font);
const int rowHeight = metrics.height();
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QPixmap face(static_cast<int>(faceSize.width() * dpr),
static_cast<int>(faceSize.height() * dpr));
face.setDevicePixelRatio(dpr);
face.fill(Qt::transparent);
QPainter painter(&face);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.setFont(font);
// Both text rows are elided rather than wrapped or shrunk, so a long name or a
// many-typed blueprint cannot change a card's height (REQ-UI-BLUEPRINT-CARD).
painter.setPen(textColor);
painter.drawText(QRect(0, 0, faceSize.width(), rowHeight),
Qt::AlignLeft | Qt::AlignVCenter,
metrics.elidedText(name, Qt::ElideRight, faceSize.width()));
painter.setPen(dimColor);
painter.drawText(QRect(0, rowHeight, faceSize.width(), rowHeight),
Qt::AlignLeft | Qt::AlignVCenter,
metrics.elidedText(contents, Qt::ElideRight, faceSize.width()));
const QSize costSize = getLogicalSize(cost);
painter.drawPixmap(0, faceSize.height() - costSize.height(), cost);
return face;
}
// A composed card face and the size to show it at; the button needs both, and only
// the composer knows the size it arrived at.
struct CardFace { QIcon icon; QSize size; };
// The two-mode face of one card. The modes differ only in the text color, so an
// unaffordable card greys itself when Qt swaps the pixmap on the disabled button
// (REQ-UI-BLUEPRINT-CARD, consistent with REQ-UI-BUILD-DISABLED).
CardFace buildCardFace(const QString& name, const QString& contents, int cost,
const QPixmap& blockIcon, const QFont& font,
const QPalette& palette)
{
const QSize faceSize = getCardFaceSize(font);
const QColor dimColor = palette.color(QPalette::Disabled, QPalette::ButtonText);
CardFace result;
for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled })
{
const QColor textColor = palette.color(
(mode == QIcon::Normal) ? QPalette::Active : QPalette::Disabled,
QPalette::ButtonText);
// The cost reads exactly like the header stock and the build button costs:
// the number, then the block icon in place of a trailing "Blocks"
// (REQ-UI-BLOCKS-ICON). A null icon leaves the bare number.
const QPixmap costPixmap =
renderCaptionWithIcon(QString::number(cost), blockIcon, font, textColor);
const QPixmap face = composeCardFace(name, contents, costPixmap, font,
textColor, dimColor, faceSize);
result.icon.addPixmap(face, mode);
result.size = result.size.expandedTo(getLogicalSize(face));
}
return result;
}
}
BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
ItemIconCache* itemIcons,
QWidget* parent)
: QDialog(parent)
, m_library(library)
, m_itemIcons(itemIcons)
{
setWindowTitle(tr("Blueprints"));
setModal(true);
// Frameless: the dialog draws its own header row, so an OS title bar would only
// repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
// rounding a top-level window needs a translucent background, which is unreliable
// on Windows. The border matches the build bar and the side panel.
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral(
"BlueprintSelectionDialog { background-color: palette(window);"
" border: 1px solid palette(mid); }"));
QVBoxLayout* mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
mainLayout->setSpacing(kSpacingPx);
QHBoxLayout* headerLayout = new QHBoxLayout();
headerLayout->setSpacing(kSpacingPx);
QLabel* titleLabel = new QLabel(tr("Blueprints"), this);
QFont headerFont = titleLabel->font();
headerFont.setBold(true);
titleLabel->setFont(headerFont);
headerLayout->addWidget(titleLabel);
// Dimmed and bold, the treatment the build button hotkey badges use, so the
// shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST).
QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this);
hotkeyBadge->setFont(headerFont);
QPalette badgePalette = hotkeyBadge->palette();
badgePalette.setColor(hotkeyBadge->foregroundRole(),
palette().color(QPalette::Disabled, QPalette::WindowText));
hotkeyBadge->setPalette(badgePalette);
headerLayout->addWidget(hotkeyBadge);
headerLayout->addStretch();
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
closeButton->setToolTip(tr("Close"));
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
headerLayout->addWidget(closeButton);
mainLayout->addLayout(headerLayout);
QScrollArea* scrollArea = new QScrollArea(this);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->setFrameShape(QFrame::NoFrame);
m_gridContainer = new QWidget(scrollArea);
m_grid = new QGridLayout(m_gridContainer);
m_grid->setContentsMargins(0, 0, 0, 0);
m_grid->setSpacing(kSpacingPx);
m_grid->setAlignment(Qt::AlignTop | Qt::AlignLeft);
scrollArea->setWidget(m_gridContainer);
mainLayout->addWidget(scrollArea, 1);
rebuildGrid();
// Fixed size (REQ-UI-BLUEPRINT-DIALOG), sized to show kVisibleRowCount card rows.
const QSize cardSize = getCardSize(font());
const int gridWidth = kGridColumnCount * cardSize.width()
+ (kGridColumnCount - 1) * kSpacingPx;
const int gridHeight = static_cast<int>(
cardSize.height() * kVisibleRowCount + kSpacingPx * (kVisibleRowCount - 1.0));
setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent)
+ 2 * kSpacingPx,
gridHeight + kSmallButtonSizePx + 3 * kSpacingPx);
// A frameless dialog does not get Qt's automatic centering on its parent.
if (parent)
{
const QRect parentRect = parent->window()->geometry();
move(parentRect.center() - QPoint(width() / 2, height() / 2));
}
}
std::optional<int> BlueprintSelectionDialog::getChosenIndex() const
{
return m_chosenIndex;
}
void BlueprintSelectionDialog::rebuildGrid()
{
// deleteLater, not delete: this runs from a delete button's own clicked signal, and
// destroying that button inside its emission would leave Qt holding a dangling
// sender. hide() makes the removal immediate all the same.
while (QLayoutItem* item = m_grid->takeAt(0))
{
if (item->widget())
{
item->widget()->hide();
item->widget()->deleteLater();
}
delete item;
}
const std::vector<Blueprint>& blueprints = m_library->getBlueprints();
if (blueprints.empty())
{
QLabel* emptyLabel = new QLabel(
tr("No blueprints yet.\n\nSelect one or more buildings and press Ctrl+C "
"to save them as a blueprint."),
m_gridContainer);
emptyLabel->setAlignment(Qt::AlignCenter);
emptyLabel->setWordWrap(true);
m_grid->addWidget(emptyLabel, 0, 0, 1, kGridColumnCount);
return;
}
// Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); null when no
// building_block icon exists, in which case the cost is the bare number.
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
: QPixmap();
const QSize cardSize = getCardSize(font());
for (int i = 0; i < static_cast<int>(blueprints.size()); ++i)
{
const Blueprint& blueprint = blueprints[static_cast<std::size_t>(i)];
const CardFace face = buildCardFace(
blueprint.name, m_library->getContentsSummary(i), m_library->getCost(i),
blockIcon, font(), palette());
QWidget* card = new QWidget(m_gridContainer);
card->setFixedSize(cardSize);
QVBoxLayout* cardLayout = new QVBoxLayout(card);
cardLayout->setContentsMargins(0, 0, 0, 0);
QPushButton* body = new QPushButton(card);
body->setIcon(face.icon);
body->setIconSize(face.size);
// An unaffordable card is greyed and does not respond, but its delete icon
// stays live (REQ-UI-BLUEPRINT-CARD).
body->setEnabled(m_library->getCanAfford(i));
cardLayout->addWidget(body);
// A sibling of the body rather than one of its children, and outside the
// layout: Qt disables a widget's children along with it, and the delete icon
// must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE).
QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card);
deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
deleteButton->setToolTip(tr("Delete blueprint"));
deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2,
cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2);
deleteButton->raise();
m_grid->addWidget(card, i / kGridColumnCount, i % kGridColumnCount);
const int index = i;
connect(body, &QPushButton::clicked, this, [this, index]()
{
onCardClicked(index);
});
connect(deleteButton, &QPushButton::clicked, this, [this, index]()
{
onDeleteClicked(index);
});
}
}
void BlueprintSelectionDialog::onCardClicked(int index)
{
// Placement mode is entered by the caller once the dialog has closed
// (REQ-UI-BLUEPRINT-CARD).
m_chosenIndex = index;
accept();
}
void BlueprintSelectionDialog::onDeleteClicked(int index)
{
// No confirmation prompt, and the dialog stays open (REQ-UI-BLUEPRINT-DELETE).
m_library->remove(index);
rebuildGrid();
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include <optional>
#include <QDialog>
class BlueprintLibrary;
class ItemIconCache;
class QGridLayout;
class QWidget;
// The blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG): a frameless modal panel
// showing every saved blueprint as a card in a scrolling two-column grid. The caller
// pauses the game and raises the dim overlay while it is open.
//
// Clicking a card accepts the dialog and reports that blueprint's index; the caller
// enters placement mode afterwards, so the dialog is already closed by then
// (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the
// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape and the close button dismiss it with
// no other effect.
class BlueprintSelectionDialog : public QDialog
{
Q_OBJECT
public:
// Neither the library nor the icon cache is owned; both outlive the dialog.
BlueprintSelectionDialog(BlueprintLibrary* library, ItemIconCache* itemIcons,
QWidget* parent = nullptr);
std::optional<int> getChosenIndex() const;
private:
void rebuildGrid();
void onCardClicked(int index);
void onDeleteClicked(int index);
BlueprintLibrary* m_library;
ItemIconCache* m_itemIcons;
QWidget* m_gridContainer;
QGridLayout* m_grid;
std::optional<int> m_chosenIndex; // nullopt = dismissed without picking
};

View File

@@ -96,16 +96,6 @@ namespace
return result;
}
// A pixmap's size in device-independent pixels. The pixmaps composed here are
// rasterized at the device pixel ratio, so their raw size is not their layout size.
QSize getLogicalSize(const QPixmap& pixmap)
{
if (pixmap.isNull()) { return QSize(0, 0); }
const qreal dpr = pixmap.devicePixelRatio();
return QSize(static_cast<int>(pixmap.width() / dpr),
static_cast<int>(pixmap.height() / dpr));
}
// One button face: the hotkey badge in the top-left corner, the chip icon centered
// below it, and the caption — the cost, or the Deconstruct name — centered at the
// bottom (REQ-UI-BUILD-COST). The three are composed into a single pixmap because

View File

@@ -13,7 +13,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
@@ -38,7 +39,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp

View File

@@ -46,3 +46,11 @@ QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
return pixmap;
}
QSize getLogicalSize(const QPixmap& pixmap)
{
if (pixmap.isNull()) { return QSize(0, 0); }
const qreal dpr = pixmap.devicePixelRatio();
return QSize(static_cast<int>(pixmap.width() / dpr),
static_cast<int>(pixmap.height() / dpr));
}

View File

@@ -3,6 +3,7 @@
#include <QColor>
#include <QFont>
#include <QPixmap>
#include <QSize>
#include <QString>
// Renders `text` followed by `icon` to its right, vertically centered, onto a
@@ -15,6 +16,11 @@
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
const QFont& font, const QColor& textColor);
// A pixmap's size in device-independent pixels. The pixmaps composed by
// renderCaptionWithIcon and its callers are rasterized at the device pixel ratio, so
// their raw size is not the size to lay them out at.
QSize getLogicalSize(const QPixmap& pixmap);
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
// word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
// REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of

View File

@@ -5,6 +5,8 @@
#include <QKeyEvent>
#include "BlueprintSaveRequestedEvent.h"
#include "BlueprintSelectionRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h"
#include "BuildingType.h"
#include "DebugDrawToggleRequestedEvent.h"
@@ -97,6 +99,27 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
}
}
// Blueprint chords (REQ-UI-HOTKEYS). Checked ahead of the plain-key switch below,
// which binds bare A/D/W/S/R/Q/T and must not fire on a Ctrl chord. Both requests
// are decided by MainWindow, the only widget that can pause the game and dim the
// window for a modal.
if ((event->modifiers() & Qt::ControlModifier) != 0)
{
switch (event->key())
{
case Qt::Key_C:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSaveRequestedEvent>());
return true;
case Qt::Key_V:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSelectionRequestedEvent>());
return true;
default:
break;
}
}
switch (event->key())
{
case Qt::Key_A:
@@ -131,7 +154,7 @@ bool InputMapper::handleKeyPress(QKeyEvent* event)
return true;
case Qt::Key_T:
// Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
// The BlueprintPanel owns the selection and blueprint-capture logic; it decides
// The BlueprintLibrary owns the selection and blueprint-capture logic; it decides
// whether anything placeable is selected and drives placement mode from there.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintRequestedEvent>());

View File

@@ -9,12 +9,15 @@
#include <QCloseEvent>
#include <QDir>
#include <QFile>
#include <QInputDialog>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QResizeEvent>
#include <QVBoxLayout>
#include "BlueprintPanel.h"
#include "BlueprintLibrary.h"
#include "BlueprintSelectionDialog.h"
#include "BuildButtonBar.h"
#include "BuildingSystem.h"
#include "Command.h"
@@ -71,31 +74,29 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir,
m_itemIcons.get(), this);
// The blueprints have no widget of their own: they are saved with Ctrl+C and picked
// from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window
// because only it can pause the game and raise the dim overlay. Built after the
// world view because loading blueprints.toml may put a message box on screen.
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
sideLayout->setContentsMargins(1, 1, 1, 1);
sideLayout->setSpacing(1);
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
// Equal stretch gives the two panels half the column height each
// The selected building panel is the column's only panel and fills its height
// (REQ-UI-PANEL-COLUMN).
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
sideLayout->addWidget(m_selectedBuildingPanel, 1);
sideLayout->addWidget(m_blueprintPanel, 1);
// Draw a thin border around each of the two side-panel sections. The class
// scoped selectors keep the border on the panels themselves rather than
// cascading onto their child widgets; WA_StyledBackground lets the plain
// QWidget subclasses honor the stylesheet box (border/background).
for (QWidget* panel : { static_cast<QWidget*>(m_selectedBuildingPanel),
static_cast<QWidget*>(m_blueprintPanel) })
{
panel->setAttribute(Qt::WA_StyledBackground, true);
}
// Draw a thin border around the side panel section. The class scoped selector keeps
// the border on the panel itself rather than cascading onto its child widgets;
// WA_StyledBackground lets the plain QWidget subclass honor the stylesheet box
// (border/background).
m_selectedBuildingPanel->setAttribute(Qt::WA_StyledBackground, true);
m_sidePanel->setStyleSheet(QStringLiteral(
"SelectedBuildingPanel, BlueprintPanel {"
" border: 1px solid palette(mid); }"));
"SelectedBuildingPanel { border: 1px solid palette(mid); }"));
// Created last so it stacks above the other children; covers the whole window and
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
@@ -369,6 +370,47 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
}
}
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> /*event*/)
{
// Ctrl+C has effect only when something player-placeable is selected; otherwise no
// dialog opens at all (REQ-UI-BLUEPRINT-CREATE).
if (!m_blueprintLibrary->getCanCaptureSelection()) { return; }
// Both scopes are held across the naming dialog and the selection dialog it hands
// over to, so the dim never blinks off and the simulation is not resumed in between
// (REQ-UI-MODAL-DIM).
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
bool ok = false;
const QString name = QInputDialog::getText(
this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal,
QString(), &ok);
// Cancel, Escape, or an empty name: no blueprint, and no selection dialog.
if (!ok || name.trimmed().isEmpty()) { return; }
m_blueprintLibrary->saveSelectionAs(name.trimmed());
showBlueprintSelectionDialog();
}
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/)
{
ModalPauseScope pause(*m_gameWorldView);
ModalDimScope dim(*m_dimOverlay);
showBlueprintSelectionDialog();
}
void MainWindow::showBlueprintSelectionDialog()
{
BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(), this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenIndex().has_value())
{
// Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD
// describes: clicking a card closes the dialog and enters placement mode.
m_blueprintLibrary->beginPlacement(*dialog.getChosenIndex());
}
}
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
{
const Tick tick = m_sim->getCurrentTick();

View File

@@ -7,6 +7,8 @@
#include <QWidget>
#include "BlueprintSaveRequestedEvent.h"
#include "BlueprintSelectionRequestedEvent.h"
#include "BuildingId.h"
#include "EscapeMenuRequestedEvent.h"
#include "EventHandler.h"
@@ -28,7 +30,7 @@ class GameWorldView;
class HeaderBar;
class SelectedBuildingPanel;
class BuildButtonBar;
class BlueprintPanel;
class BlueprintLibrary;
class ItemIconCache;
class QCloseEvent;
class QResizeEvent;
@@ -39,7 +41,9 @@ class MainWindow : public QWidget,
WinEvent,
EscapeMenuRequestedEvent,
LayoutDialogRequestedEvent,
RecipeSelectionRequestedEvent>
RecipeSelectionRequestedEvent,
BlueprintSaveRequestedEvent,
BlueprintSelectionRequestedEvent>
{
Q_OBJECT
@@ -59,6 +63,8 @@ private:
void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override;
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
// by every restart path. On success the reloaded visuals are applied to this
@@ -71,6 +77,12 @@ private:
void openShipLayoutDialog(BuildingId shipyardId,
const std::string& schematicId,
const ShipLayoutConfig& currentLayout);
// Runs the blueprint selection dialog and enters placement mode for whatever the
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own:
// both callers already hold theirs, which is what keeps the dim continuous when a
// confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM).
void showBlueprintSelectionDialog();
void layoutPanels();
private:
@@ -84,7 +96,9 @@ private:
HeaderBar* m_headerBar;
SelectedBuildingPanel* m_selectedBuildingPanel;
BuildButtonBar* m_buildButtonBar;
BlueprintPanel* m_blueprintPanel;
// The saved blueprints themselves; they have no widget of their own any more and
// are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
std::unique_ptr<BlueprintLibrary> m_blueprintLibrary;
QWidget* m_sidePanel;
ModalDimOverlay* m_dimOverlay = nullptr;