2 Commits

111 changed files with 2013 additions and 6844 deletions

View File

@@ -1,5 +1,5 @@
[world] [world]
height_tiles = 40 height_tiles = 30
refund_percentage = 100 refund_percentage = 100
deconstruction_time_seconds = 0.1 deconstruction_time_seconds = 0.1
starting_building_blocks = 200 starting_building_blocks = 200
@@ -15,7 +15,7 @@ building_blocks_tooltip = "Building blocks are the currency for construction. Sp
artifact_tooltip = "Artifacts are the key to victory. Earn one by choosing the artifact reward when you destroy a set of enemy defence stations. Collect enough of them to win the game." artifact_tooltip = "Artifacts are the key to victory. Earn one by choosing the artifact reward when you destroy a set of enemy defence stations. Collect enough of them to win the game."
[regions] [regions]
asteroid_width_tiles = 60 asteroid_width_tiles = 40
player_buffer_width_tiles = 20 player_buffer_width_tiles = 20
contest_zone_width_tiles = 60 contest_zone_width_tiles = 60
enemy_buffer_width_tiles = 20 enemy_buffer_width_tiles = 20

View File

@@ -124,31 +124,12 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
Three product targets plus tests: Three product targets plus tests:
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets. - `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar, controls panel. Depends on `lib` and on Qt's OpenGL widgets module. - `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module.
- `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT).
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`. - `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
- `tests/` — Catch2 tests. Links only against `lib`. - `tests/` — Catch2 tests. Links only against `lib`.
Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa. Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
## Player Input
Every player control is declared once, in `lib/core/ControlAction.h`, and read by three consumers that must never disagree about it:
* **`ControlsPanel`** asks which actions apply and draws a row per action (REQ-UI-CONTROLS-CONTENT).
* **`InputMapper`** resolves a key press to an action and fires the event that action stands for.
* **`GameWorldView`** resolves a mouse gesture to an action and runs the branch that carries it out.
The file declares; it never performs. It holds no simulation access, fires no events, and names nothing — display strings live in `ui/ControlActionText.h`, which renders each badge from the binding the resolver actually matches, so a chip cannot claim a key that does nothing. What an action *does* stays in the widget that always did it: the drag state machines, hit-testing, and command enqueuing were not moved.
Three invariants are easy to break here:
* **Do not add a shortcut straight to `InputMapper`'s switch or `mousePressEvent`'s branches.** Add the action and its binding to the table; the handler switches on the resolved action. A binding added directly is invisible to the panel, which is the drift the table exists to prevent. (Build hotkeys and `F3`/`F4` are deliberate exceptions, documented in the header and in REQ-UI-CONTROLS-ACCURACY.)
* **Availability and display are different questions.** An action can be live in a context the panel does not advertise it in — `Ctrl`+click with an empty selection is the standing example. `isControlActionAvailable` answers the first, the per-context row lists answer the second, and `ControlActionTest` asserts the pairing that matters: every row's bindings resolve back to that row's action.
* **Gesture state is shared, not owned by an action.** Whether a belt drag is in progress decides what the right mouse button means, so it lives on `BuildModeController` where the resolver can see it — as does the hovered-transfer flag. `m_boxSelecting` is likewise one gesture serving two actions (box select and deconstruct area).
`ControlContext` is a plain snapshot rather than references to the live controllers, which is what keeps the rules testable without a world and stops an action reaching into the simulation: if a rule needs a fact, the fact is named in the struct and the caller supplies it. When bindings become player-configurable, only the binding tables in `ControlAction.cpp` turn from hard-coded data into loaded data.
## Belt Subsystem ## Belt Subsystem
Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks. Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.

View File

@@ -428,7 +428,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
### Layout ### Layout
The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) beside whatever is currently selected, shown only while something is selected and holding its place on the screen until the next selection, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG): The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. Both permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, and the selection panel (REQ-UI-SELECTION-PANEL) at its right edge, centered vertically within the height left above the build button bar and shown only while something is selected. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
``` ```
+-----------------------------------------------------------+ +-----------------------------------------------------------+
@@ -439,10 +439,9 @@ The screen is a single column: a header bar across the top and the game world vi
| Game World | Panel | | | Game World | Panel | |
| +-----------+ | | +-----------+ |
| | | |
| +----------+ | | +------------------+ |
| | Controls | +------------------+ | | | Build Button Bar | |
| | Panel | | Build Button Bar | | +------------------+------------------+---------------------+
+-+----------+-----+------------------+---------------------+
(full window width) (full window width)
``` ```
@@ -455,17 +454,14 @@ The screen is a single column: a header bar across the top and the game world vi
- REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views). - REQ-UI-PAUSE-BORDER: While the game is paused (speed 0×, whether set via the speed controls (REQ-UI-SPEED), the Space toggle (REQ-UI-HOTKEYS), or an auto-pausing modal), a vignette border is drawn around the edges of the game world view to make the paused state hard to miss. The border is black and fades in the alpha channel from fully transparent at its inner (center-facing) edge to 50% opacity at the viewport edge, over a thickness of 100 pixels (capped at half the smaller viewport dimension on very small views).
- 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-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-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 width of the game window and the full height below the header bar. No widget insets it: the build button bar (REQ-UI-BUILD-BAR), the selection panel (REQ-UI-SELECTION-PANEL), and the controls panel (REQ-UI-CONTROLS-PANEL) float over it. - REQ-UI-WORLD-SIZE: The game world view occupies the full width of the game window and the full height below the header bar. No widget insets it: the build button bar (REQ-UI-BUILD-BAR) and the selection panel (REQ-UI-SELECTION-PANEL) float over it.
- REQ-UI-SELECTION-PANEL: The **selection panel** (the panel described under Selection Panel, REQ-UI-SINGLE-SELECTION and following) is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), placed **beside the objects it describes** rather than at a fixed corner of the view, so it appears where the player is already looking. It is **sized to its content in both width and height**, so it grows and shrinks as the content changes. It keeps the same small margin from the view's edges that it uses as its gap from the selection. - REQ-UI-SELECTION-PANEL: The **selection panel** (the panel described under Selection Panel, REQ-UI-SINGLE-SELECTION and following) is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), anchored to the view's right edge with a small margin. It is **sized to its content in both width and height**, so it grows and shrinks as the selection changes, staying right-anchored as its size changes.
- **Anchor rectangle.** The panel is placed against the screen rectangle of the selection **at the moment that selection started**: the footprint of the single object selected (a building or construction site, an actor, or a piece of debris), or, when the selection started as a multi-selection (REQ-UI-MULTI-SELECT), the bounding box of all the objects it started with.
- **Side.** The panel goes to the **right** of the anchor rectangle, separated from it by the panel's margin, whenever it fits within the view there. Otherwise it goes to the **left** of the anchor rectangle by that same margin. When it fits on neither side — a bounding box spanning most of the view, or an object too close to an edge — it is placed on whichever side leaves more room and then pushed inside the view. That is the one case in which the panel covers part of the selection. The panel's vertical placement and maximum height are defined against an **available band**: the height of the game world view, less the panel's top and bottom margins, less the height of the horizontal strip occupied by the build button bar at the bottom of the view (the bar's height plus its bottom margin, REQ-UI-BUILD-BAR) — the bar's band is subtracted over the full view width, regardless of how wide the bar currently is. The panel is **vertically centered within that available band**, not within the full view height, so it sits slightly above the view's vertical center. Should its content ever be taller than the band, the panel's height is capped at the band height and the content scrolls vertically within it.
- **Vertical placement.** The panel's **top edge is aligned with the anchor rectangle's top edge** and it extends downward. Its bottom is limited by the lowest of: the view's bottom edge less the panel's margin; and the top edge, less that margin, of the build button bar (REQ-UI-BUILD-BAR) or the controls panel (REQ-UI-CONTROLS-PANEL) — but each of those two only where the panel's own horizontal extent actually overlaps that widget's current rectangle, so a panel whose column misses them is not shortened by them. Should the panel not fit above that limit, it is shifted up, as far as the view's top margin and no further; if it still does not fit, its height is capped at the space available there and the content scrolls vertically within it.
- **Fixed for the life of the selection.** The anchor rectangle and the side are determined once, when the selection starts, and are not revisited while that selection lasts; the panel's own size is the only thing that may still move it (see **Resizing in place** below). The panel **keeps its place on the screen** when the player scrolls the view (REQ-UI-SCROLL) and when a selected object moves under it (a selected ship flying away), rather than following the object — which may leave it beside nothing, or beside an object that has left the view entirely. It likewise does not move when the selection is **expanded** by adding objects or reduced by removing them (REQ-UI-MULTI-SELECT), nor when a selected object is destroyed or deconstructed. Starting a **new** selection — clicking a different object, or a box drag that replaces the selection — places the panel anew against the new anchor rectangle.
- **Resizing in place.** Only the anchor rectangle and the chosen side are fixed for the life of the selection; the panel's geometry is **re-solved from them** whenever its content size changes (a section appearing or disappearing as the selection's state changes), the view is resized, or the build button bar's or controls panel's rectangle changes. Re-solving keeps the two edges the panel was placed by — its top edge, and the edge facing the anchor rectangle (its left edge when it sits to the right of the selection, its right edge when it sits to the left) — so the panel grows away from the selection rather than over it, and it never switches sides for as long as the selection lasts. What re-solving may change is the vertical result: growth that would take the panel outside the view or into either of those two widgets is resolved as in **Vertical placement** above, by shifting it up and capping its height, and a panel that shrinks again regains the room.
- **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible. - **Visibility.** The panel is shown only while at least one object is selected. With an empty selection it is not shown at all (REQ-UI-EMPTY-SELECTION), leaving the full game world view visible.
- **Overlay behavior.** As for the build button bar (REQ-UI-BUILD-BAR): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel. The panel never overlaps the build button bar or the controls panel, because it stays above both wherever their rectangles meet its own; neither of them ever moves on the panel's account (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL). - **Overlay behavior.** As for the build button bar (REQ-UI-BUILD-BAR): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full right-hand band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel. The panel and the build button bar never overlap, because the panel's available band excludes the bar's strip; the bar itself never moves on the panel's account (REQ-UI-BUILD-BAR).
- **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). - **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG).
- 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 widgets floating over it (the build button bar, REQ-UI-BUILD-BAR, the selection panel, REQ-UI-SELECTION-PANEL, and the controls panel, REQ-UI-CONTROLS-PANEL) — 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. - 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 widgets floating over it (the build button bar, REQ-UI-BUILD-BAR, and the selection panel, REQ-UI-SELECTION-PANEL) — 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 ### Game World
@@ -508,8 +504,6 @@ The screen is a single column: a header bar across the top and the game world vi
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused. - **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. - **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
These shortcuts are the definition; the controls panel (REQ-UI-CONTROLS-PANEL) displays the subset of them that applies to the player's current situation, and the build buttons carry the build hotkeys on their badges (REQ-UI-BUILD-COST). Neither display defines a binding of its own.
### Debug Draw ### Debug Draw
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`. - REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
@@ -533,64 +527,22 @@ The screen is a single column: a header bar across the top and the game world vi
The selection panel shows the details of the current selection, whatever its category (REQ-UI-SELECTION-CATEGORIES): buildings and construction sites, ships, defence stations, and debris. Its position, size, and overlay behavior are defined in REQ-UI-SELECTION-PANEL; the requirements below define its content. The selection panel shows the details of the current selection, whatever its category (REQ-UI-SELECTION-CATEGORIES): buildings and construction sites, ships, defence stations, and debris. Its position, size, and overlay behavior are defined in REQ-UI-SELECTION-PANEL; the requirements below define its content.
The panel shows exactly one **content** at a time, picked from the catalog in REQ-UI-SELECTION-CONTENT by what is selected. Every content is assembled from the same small set of parts and follows the same card structure (REQ-UI-SELECTION-CARD), so different selections read alike and a part means the same thing wherever it appears.
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected. - REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the selection panel is not shown at all — it is hidden rather than shown empty, so the full game world view is visible (REQ-UI-SELECTION-PANEL). It reappears as soon as an object is selected.
- REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT). - REQ-UI-SELECTION-CATEGORIES: **Selection categories and precedence.** Every selectable object belongs to one of two mutually exclusive selection categories: **buildings** (buildings and construction sites) and **field objects** (ships and defence stations — player or enemy — together with debris). A single selection holds objects from only one category at a time. Field objects of different kinds may be selected together (e.g. several ships plus debris, freely mixing player and enemy actors). Buildings are exclusive and take precedence — **buildings win**: selecting a building (by click, Ctrl+click, or a box-drag covering at least one building) clears any field selection and yields a buildings-only selection, and conversely selecting any field object clears any building selection. Point hit-testing prefers a building over a coincident field object, and among field objects prefers an actor (ship or defence station) over a coincident piece of debris (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT).
- REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom: - 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).
- **Header** — always shown. It holds the selection's identity symbol on the left — the building's icon glyph (REQ-UI-WORLD-ICON), a ship's schematic color swatch, or the kind symbol of a defence station or piece of debris — the selection's name beside it, and one optional **right slot**. The right slot holds a status indicator (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an object count — never more than one of them; which one applies is stated per content in REQ-UI-SELECTION-CONTENT. - REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selection 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.
- **Configuration group** — the controls that change how the selected object is set up: the recipe/schematic selection control (REQ-UI-SELECT-BUTTON), a shipyard's layout preview and Configure button (REQ-MOD-UI-PREVIEW), and a splitter's output filters (REQ-BLD-SPLITTER). It is shown identically for an operational building and for a construction site of the same type (REQ-BLD-SITE-CONFIG).
- **Runtime group** — what the object is currently doing: buffer contents, production progress, HP, remaining scrap, and the belt clear action (REQ-UI-BELT-CLEAR). Where the object has **HP**, its bar is the first thing in this group, above everything else the card shows (REQ-UI-HQ-PANEL, REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL) — how close the thing is to dying outranks what it is holding. For a **construction site** the entire runtime group is replaced by a captioned `Construction` section: a progress bar filled to the site's construction completion with that completion as an integer percentage beside the caption — the same value the world draws on the footprint (REQ-UI-CONSTRUCTION-PROGRESS) — followed by a note that buffers appear once the building is built, because a site has neither buffers nor a production cycle (REQ-BLD-SITE-CONFIG). That section sits **directly below the header, above the configuration group**, so how far along the site is reads first; the configuration group is otherwise unaffected and stays visible on a site.
A group with nothing to show takes no space, so a content may consist of a header alone. Within a group, related parts form **sections** carrying a short caption above them (e.g. `Layout`, `Input buffers`, `Production`, `Output buffer`); a section and its caption are shown only while that section has content, so e.g. a Miner (which consumes nothing) shows no input buffer section.
- REQ-UI-SELECTION-CONTENT: **Content catalog.** Which content the panel shows follows from the selection alone:
| Selection | Header right slot | Configuration group | Runtime group |
|---|---|---|---|
| Miner, Assembler | status | recipe control + recipe summary | buffers + production |
| Smelter, Reprocessing Plant | status | recipe summary | buffers + production |
| Shipyard | status | schematic control + layout preview + Configure | buffers + production |
| Salvage Bay | status | — | buffers |
| HQ | — | — | block stock + HP |
| Belt, Tunnel Entry, Tunnel Exit | count | — | clear action |
| Splitter | — | output filters | clear action |
| Several buildings | — | — | type counts + total cost |
| One ship | behavior | — | HP + hull stats + module summaries |
| One defence station | — | — | HP + stats |
| Debris (one or several) | count | — | remaining scrap |
| Several mixed field objects | count | — | type counts + scrap total |
Selections sharing a row of this table get the same content and differ only in the name and symbol in the header. A count in the right slot appears only for an aggregated multi-selection (REQ-UI-SELECTION-AGGREGATE); a single selection of those types shows an empty slot.
- REQ-UI-SELECTION-STATUS: **Status indicator.** For a building whose production state is already rendered in the world as a status light (REQ-UI-STATUS-LIGHT) — Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, Salvage Bay — the header's right slot repeats that same state as a colored dot with a short caption beside it, so the panel and the world never disagree. The state is derived from the evaluation defined in REQ-UI-STATUS-LIGHT rather than from a second definition, and the dot uses that state's fill color from `visuals.toml [status_light]`. The captions name the state: `no recipe` (grey), `producing` (green), `missing input` (red), `output full` (yellow); for the Salvage Bay, `holding scrap` (green) and `empty` (red). A selected **construction site** shows the caption `constructing` with no dot, whatever its type. Buildings with no status light — belts, splitters, tunnel ends, the HQ — show nothing in the slot.
- REQ-UI-SELECTION-AGGREGATE: **Aggregating a homogeneous multi-selection.** When several objects are selected and their content can be shown as one — the same content, with its values aggregated over the whole selection — the panel shows that single content with the number of selected objects in the header's right slot (`x<count>`), instead of the count summary of REQ-UI-MULTI-SELECTION / REQ-UI-FIELD-MULTI-SELECTION. This applies exactly where every part of the content aggregates:
- **Belt-subsystem tiles** — any mix of belts, tunnel entries, and tunnel exits. Their content is the clear action alone, which already acts on the whole selection (REQ-UI-BELT-CLEAR).
- **Debris** — several pieces of debris and nothing else. Their remaining scrap sums into one value (REQ-UI-DEBRIS-PANEL).
Every other multi-selection falls back to the count summary. In particular a selection mixing a splitter with belts does not aggregate (a splitter carries per-object output filters, which have no aggregate), and neither do several production buildings of one type (per-building buffers and cycle progress have no aggregate).
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows its symbol and name in the header (REQ-UI-SELECTION-CARD), its current recipe or schematic selection (REQ-UI-SELECT-BUTTON) and recipe summary (REQ-UI-RECIPE-SUMMARY) in the configuration group, and its input and output buffer contents in the runtime group. Each buffered item is shown as an **item chip** bearing that item's icon (REQ-UI-ITEM-ICON) and its current count:
- an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one;
- an **output** chip shows the count against the output buffer's capacity as `a / b` (REQ-MAT-OUTPUT-BUFFER), with the item's name below.
Input and output chips form separately captioned sections (REQ-UI-SELECTION-CARD). A section lists a chip for **every item the building's cycle involves**, and for an auto-recipe building every item it handles at all, whether or not the buffer currently holds any: an empty buffer reads `0` rather than its chip disappearing, so the card keeps one shape while the building runs. A section left with no chips at all is not shown. The production section (REQ-UI-PRODUCTION-PROGRESS) sits **between them**, so the card reads in the direction the materials flow: what goes in, what is being made of it, what has come out. For a selected construction site the buffer sections are omitted (REQ-BLD-SITE-CONFIG).
**Only unlocked items are listed.** A building's buffers may carry entries for items the player cannot make yet — an auto-recipe building's buffers are sized over *every* recipe of its type (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), including recipes that are still locked. Those entries are left out of both sections, consistent with the rest of the UI hiding what is not unlocked yet (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SPLITTER), so a Smelter shows the ores it can actually smelt rather than every ore in the game.
**An idle auto-recipe building still shows what it handles.** Having no selected recipe (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), it would otherwise show empty sections whenever it happens to be between cycles. Its input and output sections instead list the unlocked items of every recipe of its type — the same union its buffers were sized over — with a count and no per-cycle denominator, since no one recipe is in force. While a cycle is running, that cycle's recipe supplies the denominators as for any other building.
- REQ-UI-RECIPE-SUMMARY: Below the recipe/schematic selection control, a building running a recipe or schematic shows a one-line **recipe summary**: each input item's icon with its per-cycle amount, an arrow, each output item's icon with its per-cycle amount, and the cycle time in seconds. It restates what the building will do without opening the selection dialog, and it is the panel's only display of the cycle time. For a Shipyard the summary is built from the schematic's materials and production time including the placed modules' contributions (REQ-BLD-SHIPYARD, REQ-MOD-STAT-CALC), matching the buffers beneath it. Auto-recipe buildings (Smelter, Reprocessing Plant — REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) have no player-selected recipe and so show no selection control; they show the summary of the recipe currently in production and, while between cycles, of the one they ran last. They keep it rather than dropping it, because a summary that came and went with each cycle would resize the card in step with the building's status (REQ-UI-SELECTION-STATUS), which is the one thing the panel must not do while the player is reading it (REQ-UI-SELECTION-PANEL). Such a building shows no summary only until it has run its first cycle. A building with no recipe or schematic selected shows no summary.
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the panel's runtime group shows a captioned **production section** between the input and output buffer sections (REQ-UI-SINGLE-SELECTION): a horizontal progress bar filled to the completion of the active production cycle, with that completion beside the caption as an integer percentage (e.g. `72%`), or the text `idle` in place of the percentage and an empty bar when no production cycle is active. The cycle time is shown in the recipe summary (REQ-UI-RECIPE-SUMMARY) rather than repeated here. When no recipe or schematic is selected, the production section is not shown at all.
- 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-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 and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as `<n> buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x<count>` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON): 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-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, in its configuration group (REQ-UI-SELECTION-CARD). 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-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 selection 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 selection 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-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selection 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 selection 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 selection panel when a selection is set, displays an info tooltip: - 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 selection panel when a selection is set, displays an info tooltip:
- For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item. - For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item.
- For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>". - For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 <ship display name>".
- REQ-UI-RECIPE-ICON: In the recipe-selection dialog (REQ-UI-SELECT-BUTTON) for a Miner or Assembler, each recipe option button shows the icon of the recipe's produced item **instead of** its name caption (icon-only). The item shown is the recipe's `icon` field if set, otherwise its first output item; the icon is that item's icon per REQ-UI-ITEM-ICON. When the item has no icon file, the button falls back to the recipe/item name caption. The recipe name and details remain available on hover via the selection info tooltip (REQ-UI-SELECT-TOOLTIP). The `(None)` option keeps its text caption. This applies only to recipe options; the Shipyard schematic-selection dialog is unaffected and continues to show ship name captions. - REQ-UI-RECIPE-ICON: In the recipe-selection dialog (REQ-UI-SELECT-BUTTON) for a Miner or Assembler, each recipe option button shows the icon of the recipe's produced item **instead of** its name caption (icon-only). The item shown is the recipe's `icon` field if set, otherwise its first output item; the icon is that item's icon per REQ-UI-ITEM-ICON. When the item has no icon file, the button falls back to the recipe/item name caption. The recipe name and details remain available on hover via the selection info tooltip (REQ-UI-SELECT-TOOLTIP). The `(None)` option keeps its text caption. This applies only to recipe options; the Shipyard schematic-selection dialog is unaffected and continues to show ship name captions.
- REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel's runtime group shows a **"Clear stuck items"** button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. The button acts on every selected tile, which is why a selection of belts and tunnel ends aggregates into one content rather than a count summary (REQ-UI-SELECTION-AGGREGATE). - REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels.
- REQ-UI-HQ-PANEL: When the HQ is selected, the panel shows the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS) and, beneath it, the **global building blocks stock** — the same value as the header bar's stock display (REQ-UI-BLOCKS-ICON), rendered as an item chip (REQ-UI-SINGLE-SELECTION) carrying the `building_block` icon. The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site.
- REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection. - REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection.
- REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT). - REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT).
- REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are: - REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are:
- **Retreating** — the ship is retreating (REQ-SHP-RETREAT). - **Retreating** — the ship is retreating (REQ-SHP-RETREAT).
- **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI). - **Engaging** — the ship is engaging a combat target (player: REQ-SHP-COMBAT; enemy: REQ-SHP-ENEMY-AI).
- **Salvaging** — the ship is executing salvage navigation: seeking debris, collecting, or delivering to a Salvage Bay (REQ-SHP-SALVAGE). - **Salvaging** — the ship is executing salvage navigation: seeking debris, collecting, or delivering to a Salvage Bay (REQ-SHP-SALVAGE).
@@ -598,17 +550,17 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
- **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY). - **Rallying** — the ship is moving to or orbiting the rally point (REQ-SHP-RALLY).
- **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY). - **Standby** — the ship is holding with its fleet (REQ-SHP-STANDBY).
- **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid). - **Advancing** — the ship is executing the baseline forward advance with no higher-priority behavior active (player: REQ-SHP-COMBAT advance toward the enemy; enemy: REQ-SHP-ENEMY-AI advance toward the asteroid).
- REQ-UI-STATION-STATS-PANEL: When exactly one defence station is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum) as a **bar** with the two values beside its caption, then damage, range, and fire rate as label/value rows, matching the ship stats panel's rendering (REQ-UI-SHIP-STATS-PANEL). Its header carries no right slot: a station has no behavior label and no status light. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) - REQ-UI-STATION-STATS-PANEL: When exactly one defence station is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **station stats panel** displaying the station's stats computed at its current level: HP (current / maximum), damage, range, and fire rate. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.)
- REQ-UI-FIELD-MULTI-SELECTION: A full single-object stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL, REQ-UI-DEBRIS-PANEL) is shown when the field selection holds exactly one object — one ship, one defence station, or one piece of debris — and, for debris only, when it holds several pieces of debris and nothing else, which aggregate into that same content (REQ-UI-SELECTION-AGGREGATE). Every other field selection of more than one object — multiple actors, or any mix of actors and debris — shows a **count summary** instead. Its header reads `Mixed selection` with the total number of selected objects in the right slot (REQ-UI-SELECTION-CARD). Below it is one row per type — the type's symbol, its name, and the number selected as `x<count>`, the same `x`-count notation as the recipe tooltip and the building multi-selection (REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy; all selected pieces of debris are grouped into a single `Debris` row whose count is the number of selected pieces. No per-object detail is shown. If debris is part of the selection, its row is followed by an indented sub-row giving the summed remaining scrap across all selected debris (REQ-UI-DEBRIS-PANEL). Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead. - REQ-UI-FIELD-MULTI-SELECTION: A full single-object stats panel (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL, REQ-UI-DEBRIS-PANEL) is shown only when the field selection holds exactly one object — one ship, one defence station, or one piece of debris. Whenever the selection holds more than one field object — multiple actors, multiple pieces of debris, or any mix of actors and debris — the panel shows a **compact summary** instead: a count per type, one line per type rendered as "<type> x <count>" (the same `x`-count notation as the recipe tooltip and the building multi-selection, REQ-UI-MULTI-SELECTION). Ships are grouped by schematic display name and defence stations as a group, distinguishing player from enemy; all selected pieces of debris are grouped into a single "Debris x <count>" line whose count is the number of selected debris pieces. No per-object detail and no total-object-count header are shown (consistent with the building panel). If debris is part of the selection, a final "Scrap x <total>" line is appended after the "Debris" line, summing the remaining scrap across all selected debris (REQ-UI-DEBRIS-PANEL), so all lines share uniform spacing. Building selections use REQ-UI-SINGLE-SELECTION / REQ-UI-MULTI-SELECTION instead.
- REQ-UI-DEBRIS-CLICK-SELECT: The player can click any piece of debris (REQ-RES-DEBRIS-DROP) in the game world to select it. Debris are field objects (REQ-UI-SELECTION-CATEGORIES) and can be selected together with ships and defence stations, but not with buildings. A plain click on a piece of debris makes it the sole selection, clearing any previous selection; selecting a building clears any debris (buildings win), and selecting a piece of debris clears any building selection. Hit-testing prefers a building over a coincident actor or piece of debris, and an actor (ship or defence station) over a coincident piece of debris: a piece of debris is selected only when no building or actor is under the cursor. A selected piece of debris that despawns or is fully collected (REQ-RES-DEBRIS-DROP) is removed from the selection; if no selected object remains, the panel becomes empty (REQ-UI-EMPTY-SELECTION). - REQ-UI-DEBRIS-CLICK-SELECT: The player can click any piece of debris (REQ-RES-DEBRIS-DROP) in the game world to select it. Debris are field objects (REQ-UI-SELECTION-CATEGORIES) and can be selected together with ships and defence stations, but not with buildings. A plain click on a piece of debris makes it the sole selection, clearing any previous selection; selecting a building clears any debris (buildings win), and selecting a piece of debris clears any building selection. Hit-testing prefers a building over a coincident actor or piece of debris, and an actor (ship or defence station) over a coincident piece of debris: a piece of debris is selected only when no building or actor is under the cursor. A selected piece of debris that despawns or is fully collected (REQ-RES-DEBRIS-DROP) is removed from the selection; if no selected object remains, the panel becomes empty (REQ-UI-EMPTY-SELECTION).
- REQ-UI-DEBRIS-MULTI-SELECT: Multiple pieces of debris can be selected by box-drag or by Ctrl+clicking individual pieces to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Debris shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold debris and actors together. Ctrl+clicking a piece of debris while a field selection is active adds or removes that piece within the same selection; Ctrl+clicking a piece of debris while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and debris within it). - REQ-UI-DEBRIS-MULTI-SELECT: Multiple pieces of debris can be selected by box-drag or by Ctrl+clicking individual pieces to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Debris shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold debris and actors together. Ctrl+clicking a piece of debris while a field selection is active adds or removes that piece within the same selection; Ctrl+clicking a piece of debris while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and debris within it).
- REQ-UI-DEBRIS-PANEL: When debris is selected and no actors are (REQ-UI-FIELD-MULTI-SELECTION), the selection panel shows a **debris stats panel** structured like the ship and station stats panels (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL): a header reading **"Debris"**, followed by a single stat row, **"Scrap remaining"**, in the same label/value style as a ship hull stat row. With one piece selected the row shows that piece's remaining scrap amount (REQ-RES-DEBRIS-DROP) and the header's right slot is empty. With several pieces selected the same content is shown aggregated (REQ-UI-SELECTION-AGGREGATE): the number of selected pieces appears in the header's right slot as `x<count>` and the row shows the summed remaining scrap across them. When debris is selected together with actors, the debris are instead summarized within the count summary (REQ-UI-FIELD-MULTI-SELECTION): a `Debris` row giving the number of selected pieces, followed by an indented sub-row with their summed remaining scrap. The displayed scrap value updates as selected debris are partially collected or despawn (REQ-UI-DEBRIS-CLICK-SELECT). - REQ-UI-DEBRIS-PANEL: When exactly one piece of debris is selected (and no actors, REQ-UI-FIELD-MULTI-SELECTION), the selection panel shows a **debris stats panel** structured like the ship and station stats panels (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL): a **"Debris"** heading followed by a single stat row, **"Scrap"**, showing that piece's current remaining scrap amount (REQ-RES-DEBRIS-DROP), rendered in the same label/value style as a ship hull stat row. When more than one field object is selected — multiple pieces of debris, or debris together with actors the debris are instead summarized within the compact count summary (REQ-UI-FIELD-MULTI-SELECTION): a "Debris x <count>" line giving the number of selected debris pieces, followed by a "Scrap x <total>" line summing the remaining scrap across all selected debris. The displayed scrap value(s) update as selected debris are partially collected or despawn (REQ-UI-DEBRIS-CLICK-SELECT).
### Build Button Bar ### Build Button Bar
- REQ-UI-BUILD-BAR: All placeable building types are shown as a **single horizontal row** of buttons with no grouping and no wrapping, inside a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), horizontally centered and anchored at the bottom edge with a small margin. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. The bar is sized to its buttons and re-centers whenever the set of shown buttons changes (REQ-LOCK-BUILDING) or the view is resized. - REQ-UI-BUILD-BAR: All placeable building types are shown as a **single horizontal row** of buttons with no grouping and no wrapping, inside a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), horizontally centered and anchored at the bottom edge with a small margin. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. The bar is sized to its buttons and re-centers whenever the set of shown buttons changes (REQ-LOCK-BUILDING) or the view is resized.
- **Overlay behavior.** The bar occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected (the world is not inset for the bar). The bar is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full 100-pixel bottom band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the bar. - **Overlay behavior.** The bar occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected (the world is not inset for the bar). The bar is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full 100-pixel bottom band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the bar.
- **No overlap with the selection panel.** The bar and the selection panel (REQ-UI-SELECTION-PANEL) never overlap, and keeping them apart is entirely the panel's job: the bar's position depends only on its own button set and the view size, and it never moves, re-centers, or resizes because the panel appears, disappears, or changes size. The panel instead steps around the bar's current rectangle wherever its own column would meet it (REQ-UI-SELECTION-PANEL). - **No overlap with the selection panel.** The bar and the selection panel (REQ-UI-SELECTION-PANEL) never overlap, and keeping them apart is entirely the panel's job: the bar's position depends only on its own button set and the view size, and it never moves, re-centers, or resizes because the panel appears, disappears, or changes size. The panel instead confines itself to the view height less the bar's strip (REQ-UI-SELECTION-PANEL).
- **Input.** Mouse events over the bar are consumed by the bar and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the bar does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). - **Input.** Mouse events over the bar are consumed by the bar and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the bar does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG).
- REQ-UI-BUILD-COST: Each button is **icon-only with a cost**, its face composed of three elements: the button's **hotkey badge** in the top-left corner, the building's icon (REQ-UI-BUILD-ICON) centered below it, and the building block cost centered under the icon, shown with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) to the right of the number in place of the trailing `Blocks` word, e.g. `2` then a small block icon. The building name is not shown on the button; it is shown in the button's hover tooltip instead (REQ-UI-BUILD-TOOLTIP). When no icon file exists for `building_block`, the cost is shown as the bare number. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) has no cost and shows its name as a text caption in the cost's place. - REQ-UI-BUILD-COST: Each button is **icon-only with a cost**, its face composed of three elements: the button's **hotkey badge** in the top-left corner, the building's icon (REQ-UI-BUILD-ICON) centered below it, and the building block cost centered under the icon, shown with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) to the right of the number in place of the trailing `Blocks` word, e.g. `2` then a small block icon. The building name is not shown on the button; it is shown in the button's hover tooltip instead (REQ-UI-BUILD-TOOLTIP). When no icon file exists for `building_block`, the cost is shown as the bare number. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) has no cost and shows its name as a text caption in the cost's place.
- **Hotkey badge.** The badge names the build hotkey that activates the button (REQ-UI-HOTKEYS), so the player can learn the shortcuts from the bar itself. It is rendered dimmer than the cost so it reads as secondary, but at the same size and in bold, because a smaller badge is not legible. A plain-digit hotkey is shown as the bare digit (`1`, `2`, `3`); a Shift+digit hotkey is shown with an upwards arrow prefixed and no separator (`↑1``↑6`); the Deconstruct button shows `Q`. A button whose building type has no build hotkey shows no badge and keeps the same face size, so the row stays even. - **Hotkey badge.** The badge names the build hotkey that activates the button (REQ-UI-HOTKEYS), so the player can learn the shortcuts from the bar itself. It is rendered dimmer than the cost so it reads as secondary, but at the same size and in bold, because a smaller badge is not legible. A plain-digit hotkey is shown as the bare digit (`1`, `2`, `3`); a Shift+digit hotkey is shown with an upwards arrow prefixed and no separator (`↑1``↑6`); the Deconstruct button shows `Q`. A button whose building type has no build hotkey shows no badge and keeps the same face size, so the row stays even.
@@ -617,79 +569,6 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE
- 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-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. - 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.
### Controls Panel
The controls panel tells the player which controls are available right now. It is context-sensitive: the game is always in exactly one **control context**, derived from the active build mode and the current selection, and the panel shows that context's rows and no others. Its position, size, and overlay behavior are defined in REQ-UI-CONTROLS-PANEL; its structure in REQ-UI-CONTROLS-CARD; and which rows each context shows in REQ-UI-CONTROLS-CONTENT. The panel never defines a binding: every row restates one already defined in REQ-UI-HOTKEYS or in the mouse gestures cited beside it.
- REQ-UI-CONTROLS-PANEL: The **controls panel** is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), anchored to the view's **bottom-left corner** with a small margin on both edges. It is **sized to its content in both width and height**, growing and shrinking upward from that corner as the context changes. Should its content ever be taller than the view, the panel's height is capped at the view height less its margins and the content scrolls vertically within it. The selection panel is placed beside the current selection (REQ-UI-SELECTION-PANEL) and so can reach this corner; the two never overlap, and keeping them apart is entirely the selection panel's job — this panel's position depends only on its own content, the view size, and the build button bar, and it never moves or resizes because the selection panel appears, disappears, or changes size.
- **Stepping around the build button bar.** Like the selection panel (REQ-UI-SELECTION-PANEL), the panel does **not** confine itself to the band above the build button bar's strip: the bar is horizontally centered and sized to its buttons (REQ-UI-BUILD-BAR), so it normally leaves this corner free, and the panel shares the view's bottom edge with it. The panel **rises only to avoid an actual overlap**: whenever the panel at the bottom-left corner would intersect the bar's current rectangle, it is moved up so that its bottom edge clears the bar's top by the same margin it keeps from the view's edges, and its height is capped at the space that leaves. Whether it rises therefore depends on how wide the bar and the panel currently are, and it returns to the corner as soon as they no longer meet. The bar never moves on the panel's account (REQ-UI-BUILD-BAR).
- **Visibility.** The panel is shown whenever the game is being played. Unlike the selection panel it has no empty state (REQ-UI-EMPTY-SELECTION): every context has rows, so there is never nothing to show.
- **Collapsing.** Clicking anywhere on the panel's header (REQ-UI-CONTROLS-CARD) toggles the panel between **expanded** and **collapsed**. Collapsed, it shows its header alone, keeping the context name visible and the header clickable so the panel can be expanded again; expanded, it shows the header followed by every row of the current context. The panel starts **expanded**. Changing context does not change the collapsed state: a panel collapsed in one context stays collapsed in the next, and its header updates in place. The collapsed state is presentation-only — it is not a player command, never enters the replay stream, and has no effect on the simulation. It persists for as long as the application runs, including across a restart from the escape menu (REQ-UI-GAME-MENU), and is not saved to disk.
- **Overlay behavior.** As for the build button bar and the selection panel (REQ-UI-BUILD-BAR, REQ-UI-SELECTION-PANEL): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full left-hand band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel.
- **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). The only control the panel itself offers is the header click that collapses and expands it.
- REQ-UI-CONTROLS-CARD: **Card structure.** The panel is a card with two parts, top to bottom:
- **Header** — always shown, and the panel's only interactive element (REQ-UI-CONTROLS-PANEL). It holds a colored context dot on the left, the context's name beside it in upper case, and, for contexts that define one, a **detail suffix** separated by a middle dot (`BUILD MODE · Assembler`). The name and detail per context are given in REQ-UI-CONTROLS-CONTENT.
- **Rows** — one per available control, shown only while the panel is expanded. Each row is one or more **key badges** on the left — the key or mouse button drawn as a small bordered chip — and a **label** beside them naming what it does. An action reachable two ways carries both badges in the same row (`RMB` `Q` — Exit placement) rather than occupying two rows. A row whose action leaves the current mode is drawn with the destructive badge styling, distinguishing it from the rows that act within the mode. No row is ever drawn greyed or otherwise disabled: a control the player cannot currently use is not shown at all (REQ-UI-CONTROLS-ACCURACY).
The rows that are live in every context (REQ-UI-CONTROLS-CONTENT) are shown last, under a divider and the caption `ALWAYS AVAILABLE`. This holds in every context including the General one, which has context rows of its own above the divider like any other, so the card is read the same way wherever the player is.
- REQ-UI-CONTROLS-CONTENT: **Content catalog.** The control context follows from the active build mode and the selection alone. Build modes are mutually exclusive (REQ-BLD-BUILDER-MODE), so exactly one context applies at any moment:
| Context | When | Header name | Header detail |
|---|---|---|---|
| General | no build mode active, nothing selected | `GENERAL` | — |
| Selection | no build mode active, at least one object selected | `SELECTION` | `<n> buildings` or `<n> objects` |
| Build | builder mode active (REQ-BLD-BUILDER-MODE) | `BUILD MODE` | the building type's name |
| Blueprint | blueprint placement mode active (REQ-UI-BLUEPRINT-MODE) | `BLUEPRINT MODE` | the blueprint's name, or `Temporary` for a temporary blueprint (REQ-UI-BLUEPRINT-TEMP) |
| Deconstruct | deconstruct mode active (REQ-UI-DECONSTRUCT-BUTTON) | `DECONSTRUCT MODE` | — |
The Selection context's detail counts the selection and names its category (REQ-UI-SELECTION-CATEGORIES): `<n> buildings` for a building selection, `<n> objects` for a field selection, in the singular at a count of one. The Build and Blueprint contexts show **the same rows** and differ only in their header.
**Always-available rows**, shown in every context:
| Badges | Label | Shown |
|---|---|---|
| `A` `D` | Move | always |
| `W` `S` | Game speed | always |
| `Space` | Toggle pause | always |
| `V` | Paste last | only while a temporary blueprint exists (REQ-UI-BLUEPRINT-TEMP) |
| `Ctrl` `V` | Blueprints | always |
| `Esc` | Menu | always |
**Context rows**, shown above the always-available block:
| Context | Badges | Label |
|---|---|---|
| General | `LMB` | Select |
| | `LMB` drag | Select area |
| | `Q` | Deconstruct mode |
| Selection | `LMB` | Select / clear selection |
| | `LMB` drag | Select area |
| | `Ctrl` `LMB` | Add / remove from selection |
| | `Ctrl` `LMB` drag | Add area to selection |
| | `Q` | Deconstruct mode |
| | `C` | Copy to temporary blueprint |
| | `Ctrl` `C` | Create blueprint |
| Build, Blueprint | `LMB` | Place |
| | `LMB` drag | Place belt line |
| | `R` / `Shift` `R` | Rotate |
| | `RMB` `Q` | Exit placement |
| Deconstruct | `LMB` | Toggle deconstruct |
| | `LMB` drag | Deconstruct area |
| | `RMB` `Q` | Exit deconstruct mode |
Four rows are conditional on more than the context, because the binding behind them is:
- **`C` / `Ctrl` `C`** are shown only while the selection holds at least one player-placeable building, the condition under which those keys do anything (REQ-UI-HOTKEYS). A selection of ships, defence stations, or debris shows neither.
- **`LMB` drag — Place belt line** is shown only in builder mode for the Belt type, the only type placed by dragging (REQ-BLD-BELT-DRAG). Every other builder type and blueprint placement omit the row.
- **`RMB` `Q` — Exit placement** splits into two rows **while a belt drag is in progress**, because the two bindings then part company (REQ-BLD-BELT-DRAG): `RMB` reads **Cancel belt line** and cancels the drag while leaving builder mode active, and `Q` reads **Exit placement** and leaves the mode outright.
- **`LMB` — Place** reads **Apply settings** instead whenever the ghost under the cursor resolves to a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) — a single-building blueprint hovering a same-type building of a configurable type, which is the case in which clicking hands over settings rather than placing anything. A blueprint holding more than one building keeps the `Place` label, since its click both places and transfers (REQ-UI-BLUEPRINT-PLACE).
- REQ-UI-CONTROLS-ACCURACY: **The panel never advertises a binding that would do nothing.** Every row shown must, if triggered in the situation the panel is showing it in, have the effect its label names; a binding that is inert in the current context is omitted rather than shown greyed (REQ-UI-CONTROLS-CARD). The relation holds in one direction only: the panel may omit a binding that is available, and deliberately does so in three cases:
- **Build hotkeys** (REQ-UI-HOTKEYS) are live in every context, but are advertised on the build buttons' badges (REQ-UI-BUILD-COST) instead of taking eleven rows in every context of this panel.
- **`C` and `Ctrl` `C`** are omitted from the Build, Blueprint, and Deconstruct contexts even though a selection surviving into a build mode keeps them working. They belong to the Selection context, and repeating them in every mode would defeat the panel's purpose of showing what the player's current situation affords.
- **`Ctrl` `LMB` and `Ctrl` `LMB` drag** work with nothing selected — they select the object under the cursor much as a plain click would — but are shown only in the Selection context. "Add / remove from selection" names an operation on a selection, and there is none to operate on until something is selected; the plain `LMB` row already covers what the gesture does before then.
- **`F3` and `F4`** (REQ-UI-DEBUG-DRAW) are development controls rather than player controls and appear in no context.
This asymmetry between what is available and what is shown is why the two are separate questions in the implementation, and why the tests assert that a resolvable input is *available* rather than that it is displayed.
### Blueprints ### Blueprints
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). The unmodified **C** and **V** keys are the throwaway counterparts of the same two gestures: they capture and re-place a single unnamed temporary blueprint that is never saved and never listed (REQ-UI-BLUEPRINT-TEMP). Blueprints have no widget on the game screen at all. (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.) 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). The unmodified **C** and **V** keys are the throwaway counterparts of the same two gestures: they capture and re-place a single unnamed temporary blueprint that is never saved and never listed (REQ-UI-BLUEPRINT-TEMP). Blueprints have no widget on the game screen at all. (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.)

View File

@@ -10,12 +10,7 @@ set(TARGET_LIB_INCLUDE_DIRS
"${CMAKE_CURRENT_SOURCE_DIR}/lib" "${CMAKE_CURRENT_SOURCE_DIR}/lib"
"${CMAKE_CURRENT_SOURCE_DIR}/external" "${CMAKE_CURRENT_SOURCE_DIR}/external"
) )
set(TARGET_UI_INCLUDE_DIRS set(TARGET_UI_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/ui")
"${CMAKE_CURRENT_SOURCE_DIR}/ui"
# The balancing target compiles a few ui files into itself rather than linking the
# ui library, and the ship stats panel is built from the selection card's parts.
"${CMAKE_CURRENT_SOURCE_DIR}/ui/selection"
)
set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test") set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test")
set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing") set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing")
@@ -83,7 +78,6 @@ unset(SRCS)
set(HDRS) set(HDRS)
set(SRCS) set(SRCS)
set(UI_INCLUDE_PATH)
add_subdirectory(ui) add_subdirectory(ui)
@@ -112,7 +106,6 @@ set_target_properties(${TARGET_UI_NAME} PROPERTIES
) )
target_include_directories(${TARGET_UI_NAME} PUBLIC target_include_directories(${TARGET_UI_NAME} PUBLIC
"${TARGET_UI_INCLUDE_DIRS}" "${TARGET_UI_INCLUDE_DIRS}"
"${UI_INCLUDE_PATH}"
"${TARGET_LIB_INCLUDE_DIRS}" "${TARGET_LIB_INCLUDE_DIRS}"
"${LIB_INCLUDE_PATH}" "${LIB_INCLUDE_PATH}"
) )

View File

@@ -7,12 +7,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
# The card parts the ship stats panel is built from. They are deliberately free of
# Simulation and GameConfig, which is what lets them come along here.
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/StatRow.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.h
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.h
# Shared world-space shapes so the arena keeps looking like the game # Shared world-space shapes so the arena keeps looking like the game
@@ -32,10 +26,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BalancingWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp ${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/StatRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/BarRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SectionBox.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/selection/SelectionNames.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
PARENT_SCOPE PARENT_SCOPE

View File

@@ -72,7 +72,6 @@ void BuildModeController::enterMode(BuildMode mode)
std::make_shared<BuilderModeExitedEvent>()); std::make_shared<BuilderModeExitedEvent>());
break; break;
case BuildMode::Blueprint: case BuildMode::Blueprint:
m_hoveredGhostIsTransfer = false;
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintModeExitedEvent>()); std::make_shared<BlueprintModeExitedEvent>());
break; break;
@@ -250,16 +249,6 @@ void BuildModeController::setBlueprintGhostTile(QPoint tile)
m_blueprintGhostTile = tile; m_blueprintGhostTile = tile;
} }
bool BuildModeController::isHoveredGhostTransfer() const
{
return m_hoveredGhostIsTransfer;
}
void BuildModeController::setHoveredGhostTransfer(bool transfer)
{
m_hoveredGhostIsTransfer = transfer;
}
const std::optional<BuildingId>& const std::optional<BuildingId>&
BuildModeController::getDeconstructHoverBuildingId() const BuildModeController::getDeconstructHoverBuildingId() const
{ {

View File

@@ -95,15 +95,6 @@ public:
QPoint getBlueprintGhostTile() const; QPoint getBlueprintGhostTile() const;
void setBlueprintGhostTile(QPoint tile); void setBlueprintGhostTile(QPoint tile);
// Whether the ghost under the cursor would hand its settings to the building
// already there rather than place anything (REQ-UI-BLUEPRINT-TRANSFER). Classifying
// it needs the factory state, so the caller resolves it and stores the answer here,
// as with setGhostValidity. Kept here rather than recomputed per reader so the
// click, the ghost's colour, and the controls panel cannot disagree about what the
// cursor is over.
bool isHoveredGhostTransfer() const;
void setHoveredGhostTransfer(bool transfer);
// --- deconstruct mode ----------------------------------------------------- // --- deconstruct mode -----------------------------------------------------
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const; const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
void setDeconstructHoverBuildingId(std::optional<BuildingId> id); void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
@@ -127,7 +118,6 @@ private:
Blueprint m_blueprint; Blueprint m_blueprint;
QPoint m_blueprintGhostTile; QPoint m_blueprintGhostTile;
bool m_hoveredGhostIsTransfer = false;
std::optional<BuildingId> m_deconstructHoverBuildingId; std::optional<BuildingId> m_deconstructHoverBuildingId;
}; };

View File

@@ -16,10 +16,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h ${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
PARENT_SCOPE PARENT_SCOPE
) )
@@ -33,10 +31,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanelPlacement.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -1,263 +0,0 @@
#include "ControlAction.h"
namespace
{
// A binding that a belt drag can take over. Availability is a property of the action,
// but a binding can be claimed by a different action while a gesture is in progress:
// right-click cancels the drag instead of leaving builder mode (REQ-BLD-BELT-DRAG), so
// ExitMode's right-click drops out and it is left with Q alone.
enum class BindingCondition
{
Always,
NotDraggingBelt
};
struct KeyBindingEntry
{
ControlAction action;
int key;
// Shown on the badge, and Ctrl additionally participates in matching. Every other
// modifier is display only -- see resolveKeyAction.
Qt::KeyboardModifiers modifiers;
};
struct MouseBindingEntry
{
ControlAction action;
MouseBinding binding;
BindingCondition condition;
};
// Resolution is first-match-wins over these tables, so an entry that must beat another
// on the same input is listed above it -- CancelBeltLine before ExitMode on the right
// mouse button. Everything else is disjoint by availability.
const KeyBindingEntry KEY_BINDINGS[] = {
{ControlAction::Move, Qt::Key_A, Qt::NoModifier},
{ControlAction::Move, Qt::Key_D, Qt::NoModifier},
{ControlAction::GameSpeed, Qt::Key_W, Qt::NoModifier},
{ControlAction::GameSpeed, Qt::Key_S, Qt::NoModifier},
{ControlAction::TogglePause, Qt::Key_Space, Qt::NoModifier},
{ControlAction::CopyTemporary, Qt::Key_C, Qt::NoModifier},
{ControlAction::CreateBlueprint, Qt::Key_C, Qt::ControlModifier},
{ControlAction::PasteTemporary, Qt::Key_V, Qt::NoModifier},
{ControlAction::OpenBlueprints, Qt::Key_V, Qt::ControlModifier},
// One binding, two badges: Shift picks the rotation direction and is read by the
// handler, the way a build hotkey's digit is (REQ-BLD-ROTATE). Both entries match
// the same press, and both resolve to the same action, so listing them twice costs
// nothing and is what puts "R" and "Shift+R" on the row.
{ControlAction::Rotate, Qt::Key_R, Qt::NoModifier},
{ControlAction::Rotate, Qt::Key_R, Qt::ShiftModifier},
{ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier},
{ControlAction::ExitMode, Qt::Key_Q, Qt::NoModifier},
{ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier},
};
const MouseBindingEntry MOUSE_BINDINGS[] = {
{ControlAction::Select, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::Place, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::ApplySettings, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::ToggleDeconstruct, MouseBinding::LeftClick, BindingCondition::Always},
{ControlAction::SelectArea, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::PlaceBeltLine, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::DeconstructArea, MouseBinding::LeftDrag, BindingCondition::Always},
{ControlAction::AddToSelection, MouseBinding::CtrlLeftClick, BindingCondition::Always},
{ControlAction::AddAreaToSelection, MouseBinding::CtrlLeftDrag, BindingCondition::Always},
{ControlAction::CancelBeltLine, MouseBinding::RightClick, BindingCondition::Always},
{ControlAction::ExitMode, MouseBinding::RightClick, BindingCondition::NotDraggingBelt},
};
bool isConditionMet(BindingCondition condition, const ControlContext& context)
{
if (condition == BindingCondition::NotDraggingBelt) { return !context.draggingBelt; }
return true;
}
bool isPlacementMode(const ControlContext& context)
{
return context.mode == BuildMode::Builder || context.mode == BuildMode::Blueprint;
}
std::vector<ControlAction> filterAvailable(const std::vector<ControlAction>& actions,
const ControlContext& context)
{
std::vector<ControlAction> available;
for (ControlAction action : actions)
{
if (isControlActionAvailable(action, context)) { available.push_back(action); }
}
return available;
}
} // namespace
bool isControlActionAvailable(ControlAction action, const ControlContext& context)
{
switch (action)
{
case ControlAction::None:
return false;
case ControlAction::Move:
case ControlAction::GameSpeed:
case ControlAction::TogglePause:
case ControlAction::OpenBlueprints:
case ControlAction::OpenMenu:
return true;
// Does nothing until something has been captured with C, so it is not offered
// before then (REQ-UI-BLUEPRINT-TEMP, REQ-UI-CONTROLS-ACCURACY).
case ControlAction::PasteTemporary:
return context.temporaryBlueprintExists;
case ControlAction::Select:
case ControlAction::SelectArea:
case ControlAction::AddToSelection:
case ControlAction::AddAreaToSelection:
case ControlAction::EnterDeconstruct:
return context.mode == BuildMode::None;
// Both need something a blueprint can be made of; a selection of ships or debris
// leaves them inert (REQ-UI-HOTKEYS).
case ControlAction::CopyTemporary:
case ControlAction::CreateBlueprint:
return context.mode == BuildMode::None && context.placeableBuildingSelected;
// Place and ApplySettings are the same click; which one it is depends on whether
// the ghost under the cursor is a transfer target (REQ-UI-BLUEPRINT-TRANSFER).
case ControlAction::Place:
return isPlacementMode(context) && !context.hoveredGhostIsTransfer;
case ControlAction::ApplySettings:
return context.mode == BuildMode::Blueprint && context.hoveredGhostIsTransfer;
// The only building type placed by dragging (REQ-BLD-BELT-DRAG).
case ControlAction::PlaceBeltLine:
return context.mode == BuildMode::Builder
&& context.builderType == BuildingType::Belt;
case ControlAction::Rotate:
return isPlacementMode(context);
case ControlAction::CancelBeltLine:
return context.draggingBelt;
case ControlAction::ExitMode:
return context.mode != BuildMode::None;
case ControlAction::ToggleDeconstruct:
case ControlAction::DeconstructArea:
return context.mode == BuildMode::Deconstruct;
}
return false;
}
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
const ControlContext& context)
{
std::vector<ControlBinding> bindings;
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
{
if (entry.action != action) { continue; }
if (!isConditionMet(entry.condition, context)) { continue; }
ControlBinding binding;
binding.isMouse = true;
binding.mouse = entry.binding;
bindings.push_back(binding);
}
for (const KeyBindingEntry& entry : KEY_BINDINGS)
{
if (entry.action != action) { continue; }
ControlBinding binding;
binding.key = entry.key;
binding.modifiers = entry.modifiers;
bindings.push_back(binding);
}
return bindings;
}
ControlContextKind getControlContextKind(const ControlContext& context)
{
switch (context.mode)
{
case BuildMode::Builder: return ControlContextKind::Build;
case BuildMode::Blueprint: return ControlContextKind::Blueprint;
case BuildMode::Deconstruct: return ControlContextKind::Deconstruct;
case BuildMode::None: break;
}
return context.selection == ControlSelection::None ? ControlContextKind::General
: ControlContextKind::Selection;
}
std::vector<ControlAction> getContextActions(const ControlContext& context)
{
// The candidates of each context, in the order REQ-UI-CONTROLS-CONTENT lists them,
// then filtered by availability.
//
// The General and Selection lists differ rather than one filtered list serving
// both, because the additive-selection rows are an omission and not an
// unavailability: Ctrl+click does work with nothing selected, it just picks the
// object like a plain click would. "Add / remove from selection" is a row that
// means nothing until there is a selection to add to, so it waits for one
// (REQ-UI-CONTROLS-ACCURACY permits omitting an available binding).
switch (getControlContextKind(context))
{
case ControlContextKind::Build:
case ControlContextKind::Blueprint:
return filterAvailable({ControlAction::Place, ControlAction::ApplySettings,
ControlAction::PlaceBeltLine, ControlAction::Rotate,
ControlAction::CancelBeltLine, ControlAction::ExitMode},
context);
case ControlContextKind::Deconstruct:
return filterAvailable({ControlAction::ToggleDeconstruct,
ControlAction::DeconstructArea, ControlAction::ExitMode},
context);
case ControlContextKind::Selection:
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
ControlAction::AddToSelection,
ControlAction::AddAreaToSelection,
ControlAction::EnterDeconstruct,
ControlAction::CopyTemporary,
ControlAction::CreateBlueprint},
context);
case ControlContextKind::General:
break;
}
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
ControlAction::EnterDeconstruct},
context);
}
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context)
{
return filterAvailable({ControlAction::Move, ControlAction::GameSpeed,
ControlAction::TogglePause, ControlAction::PasteTemporary,
ControlAction::OpenBlueprints, ControlAction::OpenMenu},
context);
}
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
const ControlContext& context)
{
// Ctrl distinguishes a chord from the bare key (Ctrl+C is not C); every other
// modifier is ignored, so Shift+A still pans and Shift+R still rotates. This is
// what the key handler has always done, and matching modifiers exactly instead
// would silently drop those presses.
const bool controlHeld = (modifiers & Qt::ControlModifier) != 0;
for (const KeyBindingEntry& entry : KEY_BINDINGS)
{
if (entry.key != key) { continue; }
const bool entryNeedsControl = (entry.modifiers & Qt::ControlModifier) != 0;
if (entryNeedsControl != controlHeld) { continue; }
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
}
return ControlAction::None;
}
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context)
{
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
{
if (entry.binding != binding) { continue; }
if (!isConditionMet(entry.condition, context)) { continue; }
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
}
return ControlAction::None;
}

View File

@@ -1,164 +0,0 @@
#pragma once
#include <vector>
#include <Qt>
#include "BuildModeController.h"
#include "BuildingType.h"
// The single statement of what the player can do right now, and which input does it
// (REQ-UI-CONTROLS-CONTENT, REQ-UI-CONTROLS-ACCURACY).
//
// This file declares actions; it never performs them and it never names them. It knows
// an action's bindings and the situations in which it does something -- nothing about
// the simulation, the widgets, the events an action ends up firing, or the words shown
// to the player. Display text lives in ui/ControlActionText.h, which formats the
// bindings this hands it, so a badge is derived from the real binding rather than
// typed beside it.
//
// Three readers, all of them consuming this and none of them extending it:
//
// * ControlsPanel calls getContextActions()/getAlwaysAvailableActions() and draws them.
// * InputMapper calls resolveKeyAction() and fires the event the action stands for.
// * GameWorldView calls resolveMouseAction() and runs the branch it already ran.
//
// Two bindings are deliberately absent. Build hotkeys (REQ-UI-HOTKEYS) are advertised
// on the build buttons instead of in the panel, and InputMapper::getBuildHotkeyLabel
// already derives their badges from the same table the handler switches on, so they
// have no drift to fix. F3/F4 are development controls and appear nowhere
// (REQ-UI-CONTROLS-ACCURACY).
//
// When bindings become player-configurable, only the binding tables in the .cpp turn
// from hard-coded data into loaded data. The actions, the availability rules, the
// panel, and every handler are unaffected.
enum class ControlAction
{
None, // no action is bound to the queried input in the queried context
// Always available (REQ-UI-CONTROLS-CONTENT).
Move,
GameSpeed,
TogglePause,
PasteTemporary,
OpenBlueprints,
OpenMenu,
// No build mode active.
Select,
SelectArea,
AddToSelection,
AddAreaToSelection,
EnterDeconstruct,
// No build mode active, with something selected.
CopyTemporary,
CreateBlueprint,
// Builder and blueprint placement mode.
Place,
ApplySettings,
PlaceBeltLine,
Rotate,
CancelBeltLine,
ExitMode,
// Deconstruct mode.
ToggleDeconstruct,
DeconstructArea
};
// The mouse gestures that carry a binding. Each is a whole gesture rather than a raw
// event: a drag is one binding, not a press plus a release, because that is the unit
// the player and the panel both think in. Which events make up the gesture, and the
// state it runs on, stay with the widget that owns them.
enum class MouseBinding
{
LeftClick,
LeftDrag,
CtrlLeftClick,
CtrlLeftDrag,
RightClick
};
// Which selection category is held, mirroring REQ-UI-SELECTION-CATEGORIES without
// depending on SelectionController.
enum class ControlSelection
{
None,
Buildings,
FieldObjects
};
// Which card the panel is showing (REQ-UI-CONTROLS-CONTENT). Named rather than
// spelled, so the heading text stays a presentation concern.
enum class ControlContextKind
{
General,
Selection,
Build,
Blueprint,
Deconstruct
};
// Everything the availability rules are allowed to depend on, as a plain snapshot.
//
// Taking a snapshot rather than references to the live controllers is what keeps this
// testable without a world, and it is what stops an action from reaching into the
// simulation: if a rule needs a fact, the fact is named here and the caller supplies it.
struct ControlContext
{
BuildMode mode = BuildMode::None;
BuildingType builderType = BuildingType::Belt; // while mode == Builder
bool draggingBelt = false;
// A single-building blueprint whose ghost is over a configuration-transfer target,
// so clicking hands over settings rather than placing (REQ-UI-BLUEPRINT-TRANSFER).
bool hoveredGhostIsTransfer = false;
ControlSelection selection = ControlSelection::None;
int selectionCount = 0;
// At least one selected building is player-placeable, the condition under which
// C and Ctrl+C do anything (REQ-UI-HOTKEYS).
bool placeableBuildingSelected = false;
bool temporaryBlueprintExists = false;
};
// One input an action answers to, in structured form so the badge can be rendered from
// it. `modifiers` is what the badge shows; matching is looser than equality, see
// resolveKeyAction.
struct ControlBinding
{
bool isMouse = false;
MouseBinding mouse = MouseBinding::LeftClick;
int key = 0; // Qt::Key_*, when !isMouse
Qt::KeyboardModifiers modifiers = Qt::NoModifier;
};
// True when triggering the action in this context would do what its label says. The
// panel shows exactly the available actions, and the resolvers return only available
// ones, which is REQ-UI-CONTROLS-ACCURACY expressed as one function.
bool isControlActionAvailable(ControlAction action, const ControlContext& context);
// The inputs an action answers to, in the order the panel should badge them.
// Context-dependent because a binding can be taken over: while a belt drag is in
// progress the right mouse button cancels the drag, so ExitMode is left with its key
// binding alone (REQ-BLD-BELT-DRAG).
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
const ControlContext& context);
// Which card is showing, and the rows it holds -- the context's own, then the block
// available everywhere (REQ-UI-CONTROLS-CARD). Both lists are already filtered to the
// available actions and ordered as REQ-UI-CONTROLS-CONTENT lists them.
ControlContextKind getControlContextKind(const ControlContext& context);
std::vector<ControlAction> getContextActions(const ControlContext& context);
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context);
// The action a key press or a mouse gesture triggers here, or None when the input is
// unbound in this context. Both return only actions that are available, so a caller can
// act on the result without re-checking the situation.
//
// Rotation direction is not part of the action: R and Shift+R are one Rotate, and the
// caller reads the modifier for the direction, exactly as the digit of a build hotkey
// is read from the key. An action with a parameter keeps the parameter at the handler.
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
const ControlContext& context);
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context);

View File

@@ -28,7 +28,7 @@ public:
void forEach(Func&& f) const; void forEach(Func&& f) const;
template <typename... Ts> template <typename... Ts>
bool hasAll(entt::entity entity) const; bool hasAll(entt::entity entity);
template <typename T> template <typename T>
T& get(entt::entity entity); T& get(entt::entity entity);
@@ -101,7 +101,7 @@ void EntityAdmin::forEach(Func&& f) const
} }
template <typename... Ts> template <typename... Ts>
bool EntityAdmin::hasAll(entt::entity entity) const bool EntityAdmin::hasAll(entt::entity entity)
{ {
return m_registry.all_of<Ts...>(entity); return m_registry.all_of<Ts...>(entity);
} }

View File

@@ -1,76 +0,0 @@
#include "FloatingPanelPlacement.h"
#include <algorithm>
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
int leftPx, int rightPx, int marginPx)
{
int bottomPx = band.bottom();
for (const QRect& occupied : occupiedRects)
{
if (occupied.isEmpty())
{
continue;
}
// Only what is actually in the way counts: a widget entirely to one side of this
// span is not below it, however tall it is.
if (occupied.right() < leftPx || occupied.left() > rightPx)
{
continue;
}
bottomPx = std::min(bottomPx, occupied.top() - marginPx - 1);
}
return bottomPx;
}
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
int marginPx)
{
// What each side offers: the gap between the anchor and that edge of the band, less
// the margin the panel keeps from the anchor.
const int roomRightPx = band.right() - anchorRect.right() - marginPx;
const int roomLeftPx = anchorRect.left() - band.left() - marginPx;
if (roomRightPx >= widthPx)
{
return PanelSide::Right;
}
if (roomLeftPx >= widthPx)
{
return PanelSide::Left;
}
// Neither side can hold it without covering the selection, so it goes where it
// covers the least of it.
return (roomRightPx >= roomLeftPx) ? PanelSide::Right : PanelSide::Left;
}
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx)
{
const int widthPx = std::min(wantedSize.width(), band.width());
// Against the anchor on the chosen side, growing away from it: the edge facing the
// selection is the one that stays put as the panel's content resizes.
int leftPx = (side == PanelSide::Right) ? anchorRect.right() + marginPx + 1
: anchorRect.left() - marginPx - widthPx;
// A panel that does not fit there is pushed back inside the view rather than hanging
// off it, which is what puts it over the selection when neither side had room.
leftPx = std::min(leftPx, band.right() - widthPx + 1);
leftPx = std::max(leftPx, band.left());
// Only the widgets its own column meets can shorten it.
const int bottomPx = getAvailableBottomPx(band, occupiedRects, leftPx,
leftPx + widthPx - 1, marginPx);
const int heightPx =
std::min(wantedSize.height(), std::max(0, bottomPx - band.top() + 1));
// Top-aligned with the anchor, then lifted by however much of it hangs below what is
// free. Never above the band: a panel taller than the space left is capped instead,
// and scrolls.
int topPx = std::max(anchorRect.top(), band.top());
topPx = std::min(topPx, bottomPx - heightPx + 1);
topPx = std::max(topPx, band.top());
return QRect(leftPx, topPx, widthPx, heightPx);
}

View File

@@ -1,43 +0,0 @@
#pragma once
#include <vector>
#include <QRect>
#include <QSize>
// Geometry for the widgets floating over the game world view (REQ-UI-WORLD-SIZE). Their
// owner places them in one ordered pass, each into the space the earlier ones left free,
// and these are the rules they place themselves by. Pure geometry -- no widget is
// involved, which is what lets the rules be tested without a display.
// The lowest bottom edge available to a widget occupying the horizontal span
// [leftPx, rightPx] inside band: the band's own bottom, or marginPx above the topmost
// occupied rectangle whose horizontal extent meets that span. A rectangle beside the
// span is not in the way and does not shorten it (REQ-UI-SELECTION-PANEL,
// REQ-UI-CONTROLS-PANEL). The result is inclusive, as QRect::bottom() is.
int getAvailableBottomPx(const QRect& band, const std::vector<QRect>& occupiedRects,
int leftPx, int rightPx, int marginPx);
// Which side of the selection the panel stands on (REQ-UI-SELECTION-PANEL).
enum class PanelSide
{
Right,
Left
};
// The side a panel widthPx wide takes beside anchorRect: the right of it where it fits
// within band, otherwise the left, and where it fits on neither, whichever side leaves
// more room -- the one case in which the panel ends up over the selection
// (REQ-UI-SELECTION-PANEL). Decided once when the selection starts and kept for as long
// as it lasts, so a card that grows later never flips the panel across the object.
PanelSide chooseSide(const QRect& band, const QRect& anchorRect, int widthPx,
int marginPx);
// Where a panel of wantedSize stands beside anchorRect on the given side: separated from
// it by marginPx and growing away from it, its top edge on the anchor's top edge, pushed
// inside band and above whatever occupies it. The returned height is short of
// wantedSize's when there was not enough room, which is the caller's cue to scroll its
// content (REQ-UI-SELECTION-PANEL).
QRect placeBesideAnchor(const QRect& band, const QRect& anchorRect, PanelSide side,
QSize wantedSize, const std::vector<QRect>& occupiedRects,
int marginPx);

View File

@@ -9,7 +9,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BossWaveUpdatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionAnchorChangedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/GameResetEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/GameResetEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
@@ -44,7 +43,6 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/FloatingLayoutInvalidatedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/PlayerCommandsAppliedEvent.h
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -1,12 +0,0 @@
#pragma once
#include "Event.h"
// Asks the owner of the widgets floating over the game world view to re-run its placement
// pass (see ui/FloatingPanel.h). Published by a floating widget whose content or
// visibility changed: what space that widget may take depends on the ones placed before
// it, so it cannot re-place itself alone (REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL,
// REQ-UI-SELECTION-PANEL). Carries no payload -- the pass re-reads every widget.
class FloatingLayoutInvalidatedEvent : public Event
{
};

View File

@@ -1,25 +0,0 @@
#pragma once
#include <QRect>
#include "Event.h"
// Where on the screen the selection that is about to be made sits: the bounds of the one
// object selected, or of all of them when the selection starts as a multi-selection
// (REQ-UI-SELECTION-PANEL). The rectangle is in the game world view's own widget
// coordinates.
//
// Published only when a selection *starts* -- a plain click or drag, or an additive one
// onto an empty selection -- and always immediately before the selection itself. Adding
// to a selection publishes nothing, which is what leaves the selection panel where it
// is while the selection grows; and because the rectangle is screen space frozen at that
// moment, scrolling the view or a selected ship flying off does not move the panel
// either.
class SelectionAnchorChangedEvent : public Event
{
public:
explicit SelectionAnchorChangedEvent(QRect rectPx)
: rectPx(rectPx) {}
const QRect rectPx;
};

View File

@@ -45,17 +45,9 @@ bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe)
} }
std::map<std::string, int> std::map<std::string, int>
computeShipyardRequiredMaterials(const GameConfig& config, const Building& b) computeShipyardRequiredMaterials(const GameConfig& config, const Building& b)
{
return computeShipyardRequiredMaterials(config, b.recipeId, b.shipLayout);
}
std::map<std::string, int>
computeShipyardRequiredMaterials(const GameConfig& config,
const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout)
{ {
std::map<std::string, int> requiredMaterials; std::map<std::string, int> requiredMaterials;
const ShipDef* shipDef = config.ships.findShipDef(recipeId); const ShipDef* shipDef = config.ships.findShipDef(b.recipeId);
if (!shipDef) if (!shipDef)
{ {
return requiredMaterials; return requiredMaterials;
@@ -64,9 +56,9 @@ computeShipyardRequiredMaterials(const GameConfig& config,
{ {
requiredMaterials[ing.item] += ing.amount; requiredMaterials[ing.item] += ing.amount;
} }
if (shipLayout.has_value()) if (b.shipLayout.has_value())
{ {
for (const PlacedModule& pm : shipLayout->placedModules) for (const PlacedModule& pm : b.shipLayout->placedModules)
{ {
const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId); const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId);
if (!modDef) if (!modDef)

View File

@@ -38,12 +38,6 @@ bool recipeInputsAvailable(const Building& b, const RecipeDef& recipe);
std::map<std::string, int> computeShipyardRequiredMaterials(const GameConfig& config, std::map<std::string, int> computeShipyardRequiredMaterials(const GameConfig& config,
const Building& b); const Building& b);
// The same sum over a stored configuration rather than an operational building, so a
// construction site's schematic can be costed before it is built (REQ-BLD-SITE-CONFIG).
std::map<std::string, int> computeShipyardRequiredMaterials(
const GameConfig& config, const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout);
// True when a production cycle could start right now, ignoring output-buffer space. // True when a production cycle could start right now, ignoring output-buffer space.
bool hasInputsToStart(const GameConfig& config, const Building& b); bool hasInputsToStart(const GameConfig& config, const Building& b);

View File

@@ -14,10 +14,8 @@ add_files(
TunnelCompletionTest.cpp TunnelCompletionTest.cpp
WorldCoordinatesTest.cpp WorldCoordinatesTest.cpp
WorldCameraTest.cpp WorldCameraTest.cpp
FloatingPanelPlacementTest.cpp
SelectionControllerTest.cpp SelectionControllerTest.cpp
BuildModeControllerTest.cpp BuildModeControllerTest.cpp
ControlActionTest.cpp
BuildingTest.cpp BuildingTest.cpp
BuildingConfigTest.cpp BuildingConfigTest.cpp
ShipTest.cpp ShipTest.cpp

View File

@@ -1,328 +0,0 @@
#include "catch.hpp"
#include <algorithm>
#include <vector>
#include "BuildModeController.h"
#include "BuildingType.h"
#include "ControlAction.h"
// REQ-UI-CONTROLS-ACCURACY. The panel and the input handling read one table, and these
// tests are what makes that pay: the round-trip case below fails the moment a row is
// shown whose binding resolves elsewhere, which is the drift the whole design exists to
// prevent. Everything here works on action ids -- the display strings live in the ui
// target and are not what can silently go wrong.
namespace
{
ControlContext generalContext()
{
return ControlContext();
}
ControlContext selectionContext(bool placeable = true)
{
ControlContext context;
context.selection = ControlSelection::Buildings;
context.selectionCount = 3;
context.placeableBuildingSelected = placeable;
return context;
}
ControlContext builderContext(BuildingType type)
{
ControlContext context;
context.mode = BuildMode::Builder;
context.builderType = type;
return context;
}
ControlContext blueprintContext(bool transfer = false)
{
ControlContext context;
context.mode = BuildMode::Blueprint;
context.hoveredGhostIsTransfer = transfer;
return context;
}
ControlContext deconstructContext()
{
ControlContext context;
context.mode = BuildMode::Deconstruct;
return context;
}
// A spread wide enough that every availability rule and every binding condition is
// exercised by the whole-table properties below.
std::vector<ControlContext> allContexts()
{
ControlContext beltDragging = builderContext(BuildingType::Belt);
beltDragging.draggingBelt = true;
ControlContext generalWithBlueprint = generalContext();
generalWithBlueprint.temporaryBlueprintExists = true;
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
return {generalContext(),
generalWithBlueprint,
selectionContext(),
fieldSelection,
builderContext(BuildingType::Belt),
builderContext(BuildingType::Assembler),
beltDragging,
blueprintContext(false),
blueprintContext(true),
deconstructContext()};
}
std::vector<ControlAction> shownActions(const ControlContext& context)
{
std::vector<ControlAction> actions = getContextActions(context);
const std::vector<ControlAction> always = getAlwaysAvailableActions(context);
actions.insert(actions.end(), always.begin(), always.end());
return actions;
}
bool contains(const std::vector<ControlAction>& actions, ControlAction action)
{
return std::find(actions.begin(), actions.end(), action) != actions.end();
}
} // namespace
TEST_CASE("ControlAction: every shown row is available", "[controls]")
{
for (const ControlContext& context : allContexts())
{
for (ControlAction action : shownActions(context))
{
REQUIRE(isControlActionAvailable(action, context));
}
}
}
TEST_CASE("ControlAction: no row is shown twice", "[controls]")
{
for (const ControlContext& context : allContexts())
{
std::vector<ControlAction> actions = shownActions(context);
std::vector<ControlAction> unique = actions;
std::sort(unique.begin(), unique.end());
unique.erase(std::unique(unique.begin(), unique.end()), unique.end());
REQUIRE(unique.size() == actions.size());
}
}
// The core anti-drift property: a row's badges are rendered from its bindings, so every
// binding a row advertises must actually trigger that row's action in that same context.
TEST_CASE("ControlAction: every advertised binding resolves back to its own action",
"[controls]")
{
for (const ControlContext& context : allContexts())
{
for (ControlAction action : shownActions(context))
{
const std::vector<ControlBinding> bindings =
getControlActionBindings(action, context);
REQUIRE_FALSE(bindings.empty());
for (const ControlBinding& binding : bindings)
{
if (binding.isMouse)
{
REQUIRE(resolveMouseAction(binding.mouse, context) == action);
}
else
{
REQUIRE(resolveKeyAction(binding.key, binding.modifiers, context)
== action);
}
}
}
}
}
// The other direction: an input that resolves to something must resolve to an action
// that is actually available there, so no input can trigger a no-op. This is weaker
// than "must be a shown row" on purpose -- a context may omit an available binding
// (Ctrl+click in the General context), which REQ-UI-CONTROLS-ACCURACY permits; what it
// may never do is act on an unavailable one.
TEST_CASE("ControlAction: every resolvable input is available", "[controls]")
{
const std::vector<MouseBinding> mouseBindings = {
MouseBinding::LeftClick, MouseBinding::LeftDrag, MouseBinding::CtrlLeftClick,
MouseBinding::CtrlLeftDrag, MouseBinding::RightClick};
for (const ControlContext& context : allContexts())
{
for (MouseBinding binding : mouseBindings)
{
const ControlAction action = resolveMouseAction(binding, context);
if (action != ControlAction::None)
{
REQUIRE(isControlActionAvailable(action, context));
}
}
const std::vector<int> keys = {Qt::Key_A, Qt::Key_D, Qt::Key_W, Qt::Key_S,
Qt::Key_Space, Qt::Key_C, Qt::Key_V, Qt::Key_R,
Qt::Key_Q, Qt::Key_Escape};
for (int key : keys)
{
for (Qt::KeyboardModifiers modifiers :
{Qt::KeyboardModifiers(Qt::NoModifier),
Qt::KeyboardModifiers(Qt::ShiftModifier),
Qt::KeyboardModifiers(Qt::ControlModifier)})
{
const ControlAction action = resolveKeyAction(key, modifiers, context);
if (action != ControlAction::None)
{
REQUIRE(isControlActionAvailable(action, context));
}
}
}
}
}
TEST_CASE("ControlAction: contexts are named by mode and selection", "[controls]")
{
REQUIRE(getControlContextKind(generalContext()) == ControlContextKind::General);
REQUIRE(getControlContextKind(selectionContext()) == ControlContextKind::Selection);
REQUIRE(getControlContextKind(builderContext(BuildingType::Belt))
== ControlContextKind::Build);
REQUIRE(getControlContextKind(blueprintContext()) == ControlContextKind::Blueprint);
REQUIRE(getControlContextKind(deconstructContext())
== ControlContextKind::Deconstruct);
}
TEST_CASE("ControlAction: Q enters deconstruct mode, or leaves the active one",
"[controls]")
{
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext())
== ControlAction::EnterDeconstruct);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, builderContext(BuildingType::Belt))
== ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, blueprintContext())
== ControlAction::ExitMode);
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, deconstructContext())
== ControlAction::ExitMode);
}
TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not",
"[controls]")
{
const ControlContext context = selectionContext();
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, context)
== ControlAction::CopyTemporary);
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::ControlModifier, context)
== ControlAction::CreateBlueprint);
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
== ControlAction::OpenBlueprints);
// Shift+A must still pan, as it always has -- matching modifiers exactly instead of
// testing Ctrl alone would silently swallow the press.
REQUIRE(resolveKeyAction(Qt::Key_A, Qt::ShiftModifier, context) == ControlAction::Move);
REQUIRE(resolveKeyAction(Qt::Key_R, Qt::ShiftModifier, blueprintContext())
== ControlAction::Rotate);
}
TEST_CASE("ControlAction: a transfer target turns the click into Apply settings",
"[controls]")
{
const ControlContext plain = blueprintContext(false);
const ControlContext transfer = blueprintContext(true);
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, plain) == ControlAction::Place);
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, transfer)
== ControlAction::ApplySettings);
REQUIRE(contains(getContextActions(plain), ControlAction::Place));
REQUIRE_FALSE(contains(getContextActions(plain), ControlAction::ApplySettings));
REQUIRE(contains(getContextActions(transfer), ControlAction::ApplySettings));
REQUIRE_FALSE(contains(getContextActions(transfer), ControlAction::Place));
}
TEST_CASE("ControlAction: a belt drag takes the right mouse button from ExitMode",
"[controls]")
{
ControlContext dragging = builderContext(BuildingType::Belt);
dragging.draggingBelt = true;
const ControlContext idle = builderContext(BuildingType::Belt);
REQUIRE(resolveMouseAction(MouseBinding::RightClick, idle) == ControlAction::ExitMode);
REQUIRE(resolveMouseAction(MouseBinding::RightClick, dragging)
== ControlAction::CancelBeltLine);
// Q still leaves the mode outright while the drag runs, so ExitMode stays shown --
// with its right-click badge dropped, since the button now means something else.
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, dragging) == ControlAction::ExitMode);
const std::vector<ControlBinding> idleBindings =
getControlActionBindings(ControlAction::ExitMode, idle);
const std::vector<ControlBinding> dragBindings =
getControlActionBindings(ControlAction::ExitMode, dragging);
REQUIRE(idleBindings.size() == 2);
REQUIRE(dragBindings.size() == 1);
REQUIRE_FALSE(dragBindings.front().isMouse);
}
TEST_CASE("ControlAction: dragging a line is offered for belts only", "[controls]")
{
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, builderContext(BuildingType::Belt))
== ControlAction::PlaceBeltLine);
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag,
builderContext(BuildingType::Assembler))
== ControlAction::None);
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, blueprintContext())
== ControlAction::None);
}
TEST_CASE("ControlAction: blueprint keys are offered only where they do something",
"[controls]")
{
// V does nothing until C has captured something (REQ-UI-BLUEPRINT-TEMP).
ControlContext withBlueprint = generalContext();
withBlueprint.temporaryBlueprintExists = true;
REQUIRE_FALSE(contains(shownActions(generalContext()), ControlAction::PasteTemporary));
REQUIRE(contains(shownActions(withBlueprint), ControlAction::PasteTemporary));
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::NoModifier, generalContext())
== ControlAction::None);
// C and Ctrl+C need a selection holding something placeable (REQ-UI-HOTKEYS).
ControlContext fieldSelection = selectionContext(false);
fieldSelection.selection = ControlSelection::FieldObjects;
REQUIRE(contains(getContextActions(selectionContext()), ControlAction::CopyTemporary));
REQUIRE_FALSE(contains(getContextActions(fieldSelection), ControlAction::CopyTemporary));
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, fieldSelection)
== ControlAction::None);
// Ctrl+V opens the dialog whatever is going on (REQ-UI-HOTKEYS).
for (const ControlContext& context : allContexts())
{
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
== ControlAction::OpenBlueprints);
}
}
TEST_CASE("ControlAction: selection rows appear only once something is selected",
"[controls]")
{
const std::vector<ControlAction> general = getContextActions(generalContext());
const std::vector<ControlAction> selection = getContextActions(selectionContext());
REQUIRE(contains(general, ControlAction::Select));
REQUIRE(contains(general, ControlAction::EnterDeconstruct));
REQUIRE_FALSE(contains(general, ControlAction::AddToSelection));
REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::AddToSelection));
REQUIRE(contains(selection, ControlAction::AddAreaToSelection));
REQUIRE(contains(selection, ControlAction::CreateBlueprint));
REQUIRE(contains(selection, ControlAction::EnterDeconstruct));
}

View File

@@ -1,156 +0,0 @@
#include "catch.hpp"
#include <vector>
#include <QRect>
#include "FloatingPanelPlacement.h"
// The band every case below places into: 1000x600, so a bottom edge of 599.
static QRect makeBand()
{
return QRect(0, 0, 1000, 600);
}
static const int kMarginPx = 8;
TEST_CASE("With nothing in the way a widget may use the whole band", "[layout]")
{
// REQ-UI-SELECTION-PANEL: the band's own bottom is the limit when no other floating
// widget has been placed yet.
REQUIRE(getAvailableBottomPx(makeBand(), {}, 0, 999, kMarginPx) == 599);
}
TEST_CASE("A widget in the same column pushes the bottom above it", "[layout]")
{
// The build button bar sitting at the bottom center leaves the space above it, less
// the margin kept between the two (REQ-UI-BUILD-BAR).
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 350, 650, kMarginPx) == 511);
}
TEST_CASE("A widget beside the column does not shorten it", "[layout]")
{
// The controls panel in the bottom-left corner is not in the way of a panel standing
// at the right edge, however tall it is (REQ-UI-CONTROLS-PANEL).
const std::vector<QRect> occupied = { QRect(0, 100, 200, 499) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 700, 999, kMarginPx) == 599);
// Touching columns do count as meeting: the panel starts exactly where the widget
// ends, which is an overlap of one pixel.
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 199, 999, kMarginPx) == 91);
}
TEST_CASE("The topmost widget in the column decides", "[layout]")
{
// Several widgets meet the column: the one that reaches highest is the binding one,
// whatever order they are given in.
const std::vector<QRect> occupied = { QRect(400, 520, 200, 72),
QRect(0, 300, 500, 299),
QRect(900, 560, 100, 40) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 450, 550, kMarginPx) == 291);
}
TEST_CASE("An empty rectangle occupies nothing", "[layout]")
{
// A floating widget that is hidden contributes a null rect rather than being left
// out of the pass.
const std::vector<QRect> occupied = { QRect(), QRect(400, 520, 200, 0) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 0, 999, kMarginPx) == 599);
}
TEST_CASE("A widget filling the column leaves nothing", "[layout]")
{
// The caller is expected to notice that the space left is not positive rather than
// being handed a floor of its own.
const std::vector<QRect> occupied = { QRect(0, 0, 1000, 600) };
REQUIRE(getAvailableBottomPx(makeBand(), occupied, 0, 999, kMarginPx) == -9);
}
// ---------------------------------------------------------------------------
// Which side of the selection the panel takes
// ---------------------------------------------------------------------------
TEST_CASE("The panel stands to the right of the selection where it fits", "[layout]")
{
// REQ-UI-SELECTION-PANEL: right of the anchor is the first choice.
REQUIRE(chooseSide(makeBand(), QRect(100, 100, 60, 60), 300, kMarginPx)
== PanelSide::Right);
}
TEST_CASE("The panel goes left when the right cannot hold it", "[layout]")
{
// A selection near the right edge leaves 100 px there, not enough for a 300 px
// panel, and the left is wide open.
REQUIRE(chooseSide(makeBand(), QRect(880, 100, 20, 60), 300, kMarginPx)
== PanelSide::Left);
}
TEST_CASE("Fitting on neither side, the panel takes the roomier one", "[layout]")
{
// A bounding box spanning most of the view: 192 px free on the left, 92 on the
// right, and a 300 px panel fits in neither. It covers as little as it can.
REQUIRE(chooseSide(makeBand(), QRect(200, 100, 700, 200), 300, kMarginPx)
== PanelSide::Left);
REQUIRE(chooseSide(makeBand(), QRect(100, 100, 700, 200), 300, kMarginPx)
== PanelSide::Right);
}
// ---------------------------------------------------------------------------
// Where it then stands
// ---------------------------------------------------------------------------
TEST_CASE("The panel sits beside the anchor with its top edges aligned", "[layout]")
{
// REQ-UI-SELECTION-PANEL: separated by the margin, growing away from the selection,
// top edge on the anchor's top edge.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 120, 60, 60),
PanelSide::Right, QSize(300, 200), {},
kMarginPx);
REQUIRE(placed == QRect(168, 120, 300, 200));
const QRect placedLeft = placeBesideAnchor(makeBand(), QRect(500, 120, 60, 60),
PanelSide::Left, QSize(300, 200), {},
kMarginPx);
REQUIRE(placedLeft == QRect(192, 120, 300, 200));
}
TEST_CASE("A panel that would hang below the view is lifted", "[layout]")
{
// Top-aligning with a selection low in the view would put the panel's bottom past
// the band, so it rises until it fits rather than overrunning it.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 500, 60, 60),
PanelSide::Right, QSize(300, 200), {},
kMarginPx);
REQUIRE(placed == QRect(168, 400, 300, 200));
}
TEST_CASE("A panel standing over another widget rises above it", "[layout]")
{
// The controls panel in the bottom-left is in the way of a panel placed to the left
// of a selection: it clears the top of it by the margin (REQ-UI-CONTROLS-PANEL).
const std::vector<QRect> occupied = { QRect(0, 300, 260, 300) };
const QRect placed = placeBesideAnchor(makeBand(), QRect(500, 250, 60, 60),
PanelSide::Left, QSize(300, 200), occupied,
kMarginPx);
REQUIRE(placed == QRect(192, 92, 300, 200));
}
TEST_CASE("A panel taller than the space left is capped", "[layout]")
{
// Capping is the caller's cue to scroll: it asked for 700 and got what there was.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 60, 60),
PanelSide::Right, QSize(300, 700), {},
kMarginPx);
REQUIRE(placed == QRect(168, 0, 300, 600));
}
TEST_CASE("A panel that fits on neither side is pushed inside the view", "[layout]")
{
// The one case where it covers part of the selection (REQ-UI-SELECTION-PANEL): it
// stands as far from the anchor as the band allows, not off the edge of it.
const QRect placed = placeBesideAnchor(makeBand(), QRect(100, 100, 700, 200),
PanelSide::Right, QSize(300, 200), {},
kMarginPx);
REQUIRE(placed == QRect(700, 100, 300, 200));
}

View File

@@ -39,11 +39,6 @@ bool BlueprintLibrary::getCanCaptureSelection() const
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds); return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
} }
bool BlueprintLibrary::getHasTemporaryBlueprint() const
{
return m_temporaryBlueprint.has_value();
}
void BlueprintLibrary::saveSelectionAs(const QString& name) void BlueprintLibrary::saveSelectionAs(const QString& name)
{ {
Blueprint blueprint = createBlueprintFromSelection(); Blueprint blueprint = createBlueprintFromSelection();

View File

@@ -44,11 +44,6 @@ public:
// (REQ-UI-BLUEPRINT-CREATE). // (REQ-UI-BLUEPRINT-CREATE).
bool getCanCaptureSelection() const; bool getCanCaptureSelection() const;
// True once C has captured something -- the condition under which V does anything
// (REQ-UI-BLUEPRINT-TEMP), and so the condition under which the controls panel
// offers it (REQ-UI-CONTROLS-ACCURACY).
bool getHasTemporaryBlueprint() const;
// Captures the current selection under the given name and appends it to the list. // Captures the current selection under the given name and appends it to the list.
// Silently does nothing when nothing player-placeable is selected. // Silently does nothing when nothing player-placeable is selected.
void saveSelectionAs(const QString& name); void saveSelectionAs(const QString& name);

View File

@@ -2,7 +2,9 @@
#include <string> #include <string>
#include <QByteArray>
#include <QColor> #include <QColor>
#include <QFile>
#include <QFont> #include <QFont>
#include <QFontMetrics> #include <QFontMetrics>
#include <QGuiApplication> #include <QGuiApplication>
@@ -13,18 +15,18 @@
#include <QPixmap> #include <QPixmap>
#include <QPushButton> #include <QPushButton>
#include <QRect> #include <QRect>
#include <QRegularExpression>
#include <QSignalMapper> #include <QSignalMapper>
#include <QSize> #include <QSize>
#include <QString> #include <QString>
#include <QSvgRenderer>
#include "BuildingIconCache.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "BuildingTypeSelectedEvent.h" #include "BuildingTypeSelectedEvent.h"
#include "DeconstructModeToggleRequestedEvent.h" #include "DeconstructModeToggleRequestedEvent.h"
#include "DisplayName.h" #include "DisplayName.h"
#include "EventManager.h" #include "EventManager.h"
#include "ExitBuilderModeRequestedEvent.h" #include "ExitBuilderModeRequestedEvent.h"
#include "FloatingLayoutInvalidatedEvent.h"
#include "IconCaption.h" #include "IconCaption.h"
#include "InputMapper.h" #include "InputMapper.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
@@ -52,15 +54,45 @@ namespace
// Gap between the chip icon and the cost line on a button face. // Gap between the chip icon and the cost line on a button face.
const int kFaceGapPx = 2; const int kFaceGapPx = 2;
// Normal and grey-background chip pixmaps for one icon name. Empty pixmaps if the // Rasterizes a chip SVG straight at its on-screen size times the device pixel
// file cannot be read; the caller then falls back to a name caption // ratio, so it stays crisp without a downscale step.
QPixmap renderChip(const QByteArray& svg)
{
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QSvgRenderer renderer(svg);
QPixmap pixmap(static_cast<int>(kIconSize.width() * dpr),
static_cast<int>(kIconSize.height() * dpr));
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
renderer.render(&painter);
return pixmap;
}
// Normal and grey-background chip pixmaps for a "<id>.svg" file. Empty pixmaps if
// the file cannot be read; the caller then falls back to a name caption
// (REQ-UI-BUILD-ICON). // (REQ-UI-BUILD-ICON).
struct ChipPixmaps { QPixmap normal; QPixmap grey; }; struct ChipPixmaps { QPixmap normal; QPixmap grey; };
ChipPixmaps loadChipPixmaps(BuildingIconCache& icons, const std::string& iconName) ChipPixmaps loadChipPixmaps(const QString& path)
{ {
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) { return {}; }
const QByteArray svg = file.readAll();
ChipPixmaps result; ChipPixmaps result;
result.normal = icons.getChip(iconName, kIconSize.width()); result.normal = renderChip(svg);
result.grey = icons.getGreyChip(iconName, kIconSize.width());
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
// rounded background rect; the white glyph uses fill="none" and is left alone.
QString greyed = QString::fromUtf8(svg);
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
const QRegularExpressionMatch match = fillPattern.match(greyed);
if (match.hasMatch())
{
greyed.replace(match.capturedStart(), match.capturedLength(),
QStringLiteral("fill=\"#5f636e\""));
}
result.grey = renderChip(greyed.toUtf8());
return result; return result;
} }
@@ -149,12 +181,12 @@ namespace
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config, BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
BuildingIconCache* buildingIcons, const std::string& iconDir,
ItemIconCache* itemIcons, QWidget* parent) ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent) : QWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
, m_buildingIcons(buildingIcons) , m_iconDir(iconDir)
, m_itemIcons(itemIcons) , m_itemIcons(itemIcons)
{ {
// The bar floats over the rendered world rather than sitting in a panel, so it // The bar floats over the rendered world rather than sitting in a panel, so it
@@ -199,12 +231,13 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
const QString name = (def.type == BuildingType::TunnelEntry) const QString name = (def.type == BuildingType::TunnelEntry)
? tr("Tunnel") ? tr("Tunnel")
: QString::fromStdString(toDisplayName(def.id)); : QString::fromStdString(toDisplayName(def.id));
// Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's // Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's
// "tunnel_entry.svg" serves the shared Tunnel button. // "tunnel_entry.svg" serves the shared Tunnel button.
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
+ QString::fromStdString(def.id) + ".svg";
const ButtonFace face = buildButtonFace( const ButtonFace face = buildButtonFace(
loadChipPixmaps(*m_buildingIcons, def.id), loadChipPixmaps(iconPath), InputMapper::getBuildHotkeyLabel(def.type), name,
InputMapper::getBuildHotkeyLabel(def.type), name,
QString::number(def.cost), blockIcon, font(), palette()); QString::number(def.cost), blockIcon, font(), palette());
QPushButton* btn = new QPushButton(this); QPushButton* btn = new QPushButton(this);
@@ -235,7 +268,7 @@ BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
// Having no cost, it shows its name where the building buttons show theirs // Having no cost, it shows its name where the building buttons show theirs
// (REQ-UI-DECONSTRUCT-BUTTON), and its Q toggle as the badge (REQ-UI-HOTKEYS). // (REQ-UI-DECONSTRUCT-BUTTON), and its Q toggle as the badge (REQ-UI-HOTKEYS).
const ButtonFace deconstructFace = buildButtonFace( const ButtonFace deconstructFace = buildButtonFace(
loadChipPixmaps(*m_buildingIcons, "deconstruct"), loadChipPixmaps(QString::fromStdString(m_iconDir) + "/deconstruct.svg"),
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(), QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
font(), palette()); font(), palette());
@@ -271,25 +304,15 @@ BuildButtonBar::~BuildButtonBar()
unregisterForEvents(); unregisterForEvents();
} }
void BuildButtonBar::placeIn(const QRect& viewRect, void BuildButtonBar::anchorTo(const QRect& worldViewRect)
const std::vector<QRect>& /*occupiedRects*/)
{ {
if (viewRect.isNull()) m_viewRect = worldViewRect;
{ recenter();
return; }
}
// The layout drops hidden buttons from its size hint, but only once it has been
// re-run: an unlock changes which buttons are shown, and Qt would not get around to
// it before the bar is measured here.
layout()->activate();
const QSize barSize = sizeHint(); int BuildButtonBar::getStripHeightPx() const
// Centered, except that a bar wider than the view stays flush with its left edge {
// rather than hanging off both sides. return height() + kBottomMarginPx;
const int x = qMax(viewRect.left(),
viewRect.left() + (viewRect.width() - barSize.width()) / 2);
const int y = viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
setGeometry(QRect(QPoint(x, y), barSize));
} }
void BuildButtonBar::clearActiveButton() void BuildButtonBar::clearActiveButton()
@@ -339,11 +362,29 @@ void BuildButtonBar::updateVisibility()
{ {
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i])); m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
} }
// A hidden button leaves the row, so the bar takes up a new width and has to be // A hidden button leaves the row, so the bar has to take up its new width and
// re-centered on it -- and the widgets that keep clear of the bar have to be placed // re-center on it (REQ-UI-BUILD-BAR).
// against that new rect too, so the whole pass is re-run (REQ-UI-BUILD-BAR). recenter();
EventManager::getInstance()->sendEventImmediately( }
std::make_shared<FloatingLayoutInvalidatedEvent>());
void BuildButtonBar::recenter()
{
if (m_viewRect.isNull())
{
return;
}
// The layout drops hidden buttons from its size hint, but only once it has been
// re-run: updateVisibility() calls this straight after setVisible(), before Qt
// would get around to it on its own.
layout()->activate();
const QSize barSize = sizeHint();
// Centered, except that a bar wider than the view stays flush with its left edge
// rather than hanging off both sides.
const int x = qMax(m_viewRect.left(),
m_viewRect.left() + (m_viewRect.width() - barSize.width()) / 2);
const int y = m_viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
setGeometry(QRect(QPoint(x, y), barSize));
} }
void BuildButtonBar::onBuildButton(int index) void BuildButtonBar::onBuildButton(int index)

View File

@@ -15,22 +15,18 @@
#include "BuildingType.h" #include "BuildingType.h"
#include "DeconstructModeChangedEvent.h" #include "DeconstructModeChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "FloatingPanel.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "UnlockedBuildingsChangedEvent.h" #include "UnlockedBuildingsChangedEvent.h"
class QPushButton; class QPushButton;
class Simulation; class Simulation;
class BuildingIconCache;
class ItemIconCache; class ItemIconCache;
// The build menu: one horizontal row of build buttons floating over the game world // The build menu: one horizontal row of build buttons floating over the game world
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons and centers itself along the // view (REQ-UI-BUILD-BAR). The bar is sized to its buttons; its owner hands it the
// bottom edge of the rect its owner places it in. It is placed first of the floating // world view's rect through anchorTo() and it centers itself along that rect's
// widgets and so takes the space it wants outright: nothing ever moves it aside, and the // bottom edge.
// widgets placed after it keep out of its way instead.
class BuildButtonBar : public QWidget, class BuildButtonBar : public QWidget,
public FloatingPanel,
public CombinedEventHandler<BuilderModeExitedEvent, public CombinedEventHandler<BuilderModeExitedEvent,
DeconstructModeChangedEvent, DeconstructModeChangedEvent,
BuildHotkeyPressedEvent, BuildHotkeyPressedEvent,
@@ -40,19 +36,25 @@ class BuildButtonBar : public QWidget,
Q_OBJECT Q_OBJECT
public: public:
// buildingIcons supplies each button's chip icon (REQ-UI-BUILD-ICON) and itemIcons // iconDir is the directory holding the per-building "<id>.svg" chip icons
// the building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Both are // (REQ-UI-BUILD-ICON), read from disk at runtime like the config files.
// window-wide caches; neither is owned, and both must outlive this widget. // itemIcons is the window-wide per-item icon cache and supplies the
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Not
// owned; must outlive this widget.
BuildButtonBar(Simulation* sim, const GameConfig* config, BuildButtonBar(Simulation* sim, const GameConfig* config,
BuildingIconCache* buildingIcons, ItemIconCache* itemIcons, const std::string& iconDir, ItemIconCache* itemIcons,
QWidget* parent = nullptr); QWidget* parent = nullptr);
~BuildButtonBar() override; ~BuildButtonBar() override;
// Centers the bar along the bottom edge of the game world view's rect, given in the // Centers the bar along the bottom edge of the game world view's rect, given in
// bar's parent coordinates (REQ-UI-BUILD-BAR). Nothing is occupied yet when the bar // the bar's parent coordinates (REQ-UI-BUILD-BAR). The rect is remembered, so a
// is placed, so it ignores what it is handed there. // re-center later driven by an unlock needs no second call from the owner.
void placeIn(const QRect& viewRect, void anchorTo(const QRect& worldViewRect);
const std::vector<QRect>& occupiedRects) override;
// Height of the strip the bar occupies along the bottom of the world view: its own
// height plus the margin below it. The selection panel keeps out of this strip, and
// the bar never moves for the panel in return (REQ-UI-BUILD-BAR).
int getStripHeightPx() const;
void clearActiveButton(); void clearActiveButton();
@@ -66,6 +68,11 @@ private:
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden. // unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
void updateVisibility(); void updateVisibility();
// Shrinks the bar to its currently shown buttons and re-centers it in the
// anchored rect (REQ-UI-BUILD-BAR). Does nothing until anchorTo() supplied that
// rect, so the construction-time call is harmless.
void recenter();
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override; void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override; void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override; void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
@@ -78,11 +85,12 @@ private slots:
private: private:
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
BuildingIconCache* m_buildingIcons; // Not owned; lives in MainWindow. std::string m_iconDir;
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow. ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
std::vector<BuildingType> m_types; std::vector<BuildingType> m_types;
std::vector<QPushButton*> m_buttons; std::vector<QPushButton*> m_buttons;
std::map<BuildingType, int> m_costs; std::map<BuildingType, int> m_costs;
std::optional<std::size_t> m_activeIndex; std::optional<std::size_t> m_activeIndex;
QPushButton* m_deconstructButton; QPushButton* m_deconstructButton;
QRect m_viewRect;
}; };

View File

@@ -1,120 +0,0 @@
#include "BuildingIconCache.h"
#include <QFile>
#include <QGuiApplication>
#include <QPainter>
#include <QRegularExpression>
#include <QSvgRenderer>
namespace
{
// Chip background color for a build button the player cannot afford
// (REQ-UI-BUILD-DISABLED). Part of the icon rather than of any widget's palette, so it
// lives with the recoloring step.
const char* const kGreyFill = "fill=\"#5f636e\"";
} // namespace
BuildingIconCache::BuildingIconCache(const QString& iconDir)
: m_iconDir(iconDir)
{
}
const QByteArray& BuildingIconCache::getSvg(const std::string& iconName)
{
const std::map<std::string, QByteArray>::const_iterator cached =
m_svgByName.find(iconName);
if (cached != m_svgByName.end())
{
return cached->second;
}
// An absent or unreadable file caches an empty byte array so it is not retried;
// a missing icon is not an error (REQ-UI-BUILD-ICON).
QByteArray svg;
QFile file(m_iconDir + "/" + QString::fromStdString(iconName) + ".svg");
if (file.open(QIODevice::ReadOnly))
{
svg = file.readAll();
}
return m_svgByName.emplace(iconName, std::move(svg)).first->second;
}
const QByteArray& BuildingIconCache::getGreySvg(const std::string& iconName)
{
const std::map<std::string, QByteArray>::const_iterator cached =
m_greySvgByName.find(iconName);
if (cached != m_greySvgByName.end())
{
return cached->second;
}
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
// rounded background rect; the white glyph uses fill="none" and is left alone.
const QByteArray& svg = getSvg(iconName);
QByteArray greyed = svg;
if (!svg.isEmpty())
{
QString text = QString::fromUtf8(svg);
static const QRegularExpression fillPattern(
QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
const QRegularExpressionMatch match = fillPattern.match(text);
if (match.hasMatch())
{
text.replace(match.capturedStart(), match.capturedLength(),
QLatin1String(kGreyFill));
}
greyed = text.toUtf8();
}
return m_greySvgByName.emplace(iconName, std::move(greyed)).first->second;
}
bool BuildingIconCache::hasIcon(const std::string& iconName)
{
return !getSvg(iconName).isEmpty();
}
QPixmap BuildingIconCache::getChip(const std::string& iconName, int sizePx)
{
return getPixmap(iconName, getSvg(iconName), sizePx);
}
QPixmap BuildingIconCache::getGreyChip(const std::string& iconName, int sizePx)
{
return getPixmap("grey:" + iconName, getGreySvg(iconName), sizePx);
}
QPixmap BuildingIconCache::getPixmap(const std::string& cacheKey,
const QByteArray& svg, int sizePx)
{
if (sizePx <= 0)
{
return QPixmap();
}
const std::pair<std::string, int> key(cacheKey, sizePx);
const std::map<std::pair<std::string, int>, QPixmap>::const_iterator cached =
m_pixmapCache.find(key);
if (cached != m_pixmapCache.end())
{
return cached->second;
}
QPixmap pixmap;
if (!svg.isEmpty())
{
// Rasterized straight at its on-screen size times the device pixel ratio, so it
// stays crisp without a downscale step.
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QSvgRenderer renderer(svg);
pixmap = QPixmap(static_cast<int>(sizePx * dpr), static_cast<int>(sizePx * dpr));
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
renderer.render(&painter);
}
return m_pixmapCache.emplace(key, std::move(pixmap)).first->second;
}

View File

@@ -1,55 +0,0 @@
#pragma once
#include <map>
#include <string>
#include <utility>
#include <QByteArray>
#include <QPixmap>
#include <QString>
// Rasterizes and caches the per-building chip SVGs (REQ-UI-BUILD-ICON). A chip is a
// rounded colored background bearing a white line glyph, loaded from a directory with
// one file per building named after the building's id (e.g. "belt.svg"). Shared by the
// build button bar and the selection panel's card header (REQ-UI-SELECTION-CARD) so the
// rasterization is not duplicated.
//
// A missing icon file is not an error: hasIcon() returns false for it and the caller
// falls back (a name caption on a build button, a bare title in the panel header).
class BuildingIconCache
{
public:
// iconDir is the directory holding the "<id>.svg" chip files (typically
// "<configDir>/../icons/buildings").
explicit BuildingIconCache(const QString& iconDir);
// True if a chip SVG file exists for the given icon name. Loads the file's bytes on
// first query and remembers the result (including absence) so repeated calls are
// cheap. The name is a building id for the building buttons, but need not be one --
// the Deconstruct button's "deconstruct" chip goes through the same path.
bool hasIcon(const std::string& iconName);
// The chip rasterized to a transparent sizePx*sizePx pixmap at the device pixel
// ratio, cached per (name, size). Returns a null pixmap when the file is missing.
QPixmap getChip(const std::string& iconName, int sizePx);
// As getChip(), but with the chip background recolored grey and the glyph left
// alone, for a build button the player cannot currently afford
// (REQ-UI-BUILD-DISABLED).
QPixmap getGreyChip(const std::string& iconName, int sizePx);
private:
// Raw SVG bytes for an icon name, loading and caching them on first access. An
// absent file caches an empty QByteArray so it is not retried.
const QByteArray& getSvg(const std::string& iconName);
// The same SVG with its background fill replaced by grey, cached alongside.
const QByteArray& getGreySvg(const std::string& iconName);
// Shared rasterize-and-cache step. cacheKey distinguishes the normal and grey
// variants of one icon name within the single pixmap cache.
QPixmap getPixmap(const std::string& cacheKey, const QByteArray& svg, int sizePx);
QString m_iconDir;
std::map<std::string, QByteArray> m_svgByName;
std::map<std::string, QByteArray> m_greySvgByName;
std::map<std::pair<std::string, int>, QPixmap> m_pixmapCache;
};

View File

@@ -1,5 +1,3 @@
add_subdirectory(selection)
SET(HDRS SET(HDRS
${HDRS} ${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
@@ -12,12 +10,9 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h ${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
${CMAKE_CURRENT_SOURCE_DIR}/FloatingPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBounds.h ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
@@ -27,16 +22,10 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.h
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h ${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h
PARENT_SCOPE PARENT_SCOPE
) )
set(UI_INCLUDE_PATH
${UI_INCLUDE_PATH}
PARENT_SCOPE
)
SET(SRCS SET(SRCS
${SRCS} ${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp ${CMAKE_CURRENT_SOURCE_DIR}/VisualsLoader.cpp
@@ -49,9 +38,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionBounds.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlsPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
@@ -61,7 +48,6 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.cpp
${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp ${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp
PARENT_SCOPE PARENT_SCOPE
) )

View File

@@ -1,92 +0,0 @@
#include "ControlActionText.h"
#include <QCoreApplication>
#include <QKeySequence>
namespace
{
// tr() for a file of free functions. Q_DECLARE_TR_FUNCTIONS expands with access
// specifiers, so it needs a class rather than a namespace; this struct exists only to
// carry it and give the strings a single lupdate context.
struct Strings
{
Q_DECLARE_TR_FUNCTIONS(ControlActionText)
};
QString getMouseBadge(MouseBinding binding)
{
switch (binding)
{
case MouseBinding::LeftClick: return Strings::tr("LMB");
case MouseBinding::LeftDrag: return Strings::tr("LMB drag");
case MouseBinding::CtrlLeftClick: return Strings::tr("Ctrl+LMB");
case MouseBinding::CtrlLeftDrag: return Strings::tr("Ctrl+LMB drag");
case MouseBinding::RightClick: return Strings::tr("RMB");
}
return QString();
}
} // namespace
QString getControlBindingBadge(const ControlBinding& binding)
{
if (binding.isMouse) { return getMouseBadge(binding.mouse); }
// QKeySequence spells the modifiers and the key together and localizes them, which
// is what makes this stay correct once keys are rebindable: the chip is generated
// from the binding rather than typed next to it.
return QKeySequence(binding.key | static_cast<int>(binding.modifiers))
.toString(QKeySequence::NativeText);
}
QString getControlActionLabel(ControlAction action, const ControlContext& context)
{
switch (action)
{
case ControlAction::None: return QString();
case ControlAction::Move: return Strings::tr("Move");
case ControlAction::GameSpeed: return Strings::tr("Game speed");
case ControlAction::TogglePause: return Strings::tr("Toggle pause");
case ControlAction::PasteTemporary: return Strings::tr("Paste last");
case ControlAction::OpenBlueprints: return Strings::tr("Blueprints");
case ControlAction::OpenMenu: return Strings::tr("Menu");
// A click on empty space clears and a click on an object replaces; naming only the
// first would be a half-truth once something is selected.
case ControlAction::Select:
return context.selection == ControlSelection::None
? Strings::tr("Select")
: Strings::tr("Select / clear selection");
case ControlAction::SelectArea: return Strings::tr("Select area");
case ControlAction::AddToSelection: return Strings::tr("Add / remove from selection");
case ControlAction::AddAreaToSelection: return Strings::tr("Add area to selection");
case ControlAction::EnterDeconstruct: return Strings::tr("Deconstruct mode");
case ControlAction::CopyTemporary: return Strings::tr("Copy to temporary blueprint");
case ControlAction::CreateBlueprint: return Strings::tr("Create blueprint");
case ControlAction::Place: return Strings::tr("Place");
case ControlAction::ApplySettings: return Strings::tr("Apply settings");
case ControlAction::PlaceBeltLine: return Strings::tr("Place belt line");
case ControlAction::Rotate: return Strings::tr("Rotate");
case ControlAction::CancelBeltLine: return Strings::tr("Cancel belt line");
case ControlAction::ExitMode:
return context.mode == BuildMode::Deconstruct
? Strings::tr("Exit deconstruct mode")
: Strings::tr("Exit placement");
case ControlAction::ToggleDeconstruct: return Strings::tr("Toggle deconstruct");
case ControlAction::DeconstructArea: return Strings::tr("Deconstruct area");
}
return QString();
}
QString getControlContextName(ControlContextKind kind)
{
switch (kind)
{
case ControlContextKind::General: return Strings::tr("GENERAL");
case ControlContextKind::Selection: return Strings::tr("SELECTION");
case ControlContextKind::Build: return Strings::tr("BUILD MODE");
case ControlContextKind::Blueprint: return Strings::tr("BLUEPRINT MODE");
case ControlContextKind::Deconstruct: return Strings::tr("DECONSTRUCT MODE");
}
return QString();
}

View File

@@ -1,26 +0,0 @@
#pragma once
#include <QString>
#include "ControlAction.h"
// What the player is shown for the actions and bindings declared in ControlAction.h
// (REQ-UI-CONTROLS-CONTENT). Kept out of lib/core deliberately: that file decides what
// is available and what triggers it, this one decides what it is called, and only this
// half is presentation.
//
// A badge is rendered from the binding it belongs to rather than written beside it, so
// what the panel shows on a chip is what the resolver actually matches. When bindings
// become player-configurable, this is the only place that has to learn to spell a
// rebound key.
// The chip text for one binding: "Ctrl+V", "LMB drag", "RMB".
QString getControlBindingBadge(const ControlBinding& binding);
// What the action is called in this context. A few actions are named for their
// situation rather than their implementation -- one exit action reads "Exit placement"
// in a placement mode and "Exit deconstruct mode" in deconstruct mode.
QString getControlActionLabel(ControlAction action, const ControlContext& context);
// The heading, in the upper case the card shows it in (REQ-UI-CONTROLS-CARD).
QString getControlContextName(ControlContextKind kind);

View File

@@ -1,340 +0,0 @@
#include "ControlsPanel.h"
#include <QFont>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QTimer>
#include <QVBoxLayout>
#include "ControlActionText.h"
#include "EventManager.h"
#include "FloatingLayoutInvalidatedEvent.h"
#include "FloatingPanelPlacement.h"
#include "GameWorldView.h"
#include "selection/SelectionNames.h"
namespace
{
const int kMarginPx = 8; // between the view's edge and the panel
const int kCardMarginPx = 8; // inside the panel, around its content
const int kHeadingGapPx = 4; // between the heading and the rows
const int kRefreshMs = 50; // see the class comment on why this polls
// The panel's border, from the stylesheet below. Spelled out because the stylesheet box
// is what sets it and asking the style for it before the first show is unreliable.
const int kBorderPx = 1;
// Separates the heading's name from its detail, e.g. "BUILD MODE * Assembler".
const QChar kHeadingSeparator(0x00B7); // U+00B7 MIDDLE DOT
// The upper-case heading and caption are tracked out a little so they read as labels
// rather than as words. Set on the font because Qt's stylesheets have no letter-spacing
// property -- writing one there is silently ignored apart from a warning per widget.
QFont makeSpacedFont(QFont font, bool bold, int pointSizeDelta)
{
font.setBold(bold);
if (pointSizeDelta != 0 && font.pointSize() > 0)
{
font.setPointSize(qMax(1, font.pointSize() + pointSizeDelta));
}
font.setLetterSpacing(QFont::AbsoluteSpacing, 1.0);
return font;
}
// One row: the chips for the bindings, then what the action is called. Built as a plain
// widget rather than a class of its own -- it holds no state and answers no questions.
QWidget* makeRow(ControlAction action, const ControlContext& context, QWidget* parent)
{
QWidget* row = new QWidget(parent);
QHBoxLayout* layout = new QHBoxLayout(row);
layout->setContentsMargins(0, 2, 0, 2);
layout->setSpacing(4);
// Every badge is rendered from the binding the resolver matches, so a chip cannot
// claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY). The chips of the row
// that leaves the mode are the ones marked, not its label (REQ-UI-CONTROLS-CARD).
const bool exitsMode = (action == ControlAction::ExitMode);
const std::vector<ControlBinding> bindings = getControlActionBindings(action, context);
for (const ControlBinding& binding : bindings)
{
QLabel* badge = new QLabel(getControlBindingBadge(binding), row);
badge->setObjectName(exitsMode ? QStringLiteral("controlBadgeExit")
: QStringLiteral("controlBadge"));
layout->addWidget(badge);
}
QLabel* label = new QLabel(getControlActionLabel(action, context), row);
label->setObjectName(QStringLiteral("controlLabel"));
layout->addSpacing(4);
layout->addWidget(label);
layout->addStretch(1);
return row;
}
// Adds a freshly built widget to the rows and shows it.
//
// The show is what makes it count. A widget created under an already-visible parent
// starts hidden, and a layout treats a hidden item as empty -- it contributes nothing
// to the size hint until something shows it, which otherwise does not happen until the
// event loop next runs, long after the panel has measured itself.
void addAndShow(QVBoxLayout* layout, QWidget* widget)
{
layout->addWidget(widget);
widget->show();
}
} // namespace
ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
: QWidget(parent)
, m_view(view)
{
// Floats over the rendered world, so it brings its own background to stay legible
// over any world content (REQ-UI-CONTROLS-PANEL). Palette colors match the build
// button bar's and the selection panel's chrome; like them this is widget chrome
// rather than world rendering, so it is deliberately not a visuals.toml color. The
// class scoped selector keeps the border on the panel rather than cascading onto
// its children.
setAttribute(Qt::WA_StyledBackground, true);
// Letter spacing is deliberately absent here: Qt's stylesheet syntax has no such
// property and warns on every widget it is applied to. The heading and the caption
// set it on their QFont instead.
setStyleSheet(QStringLiteral(
"ControlsPanel { background-color: palette(window);"
" border: 1px solid palette(mid); border-radius: 4px; }"
"QLabel#controlHeading { color: palette(text); }"
"QLabel#controlBadge { border: 1px solid palette(mid); border-radius: 3px;"
" padding: 1px 5px; font-family: monospace; color: palette(text); }"
"QLabel#controlLabel { color: palette(text); }"
// The row that leaves the mode is marked on its chips rather than its label
// (REQ-UI-CONTROLS-CARD): the label keeps the ordinary text color, so the row
// stays legible whatever the palette, and only the chips carry the warning.
//
// A literal red because there is no palette role for "destructive" -- the
// nearest, bright-text, is white by design, being meant for text over dark
// highlights, and was unreadable on this panel's chrome. This value reads on a
// light and a dark background alike. It is widget chrome, so like the rest of
// this stylesheet it is deliberately not a visuals.toml color.
"QLabel#controlBadgeExit { border: 1px solid #c0392b; border-radius: 3px;"
" padding: 1px 5px; font-family: monospace; color: #c0392b; }"
"QLabel#controlCaption { color: palette(mid); }"));
QVBoxLayout* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
kCardMarginPx, kCardMarginPx);
outerLayout->setSpacing(kHeadingGapPx);
m_heading = new QLabel(this);
m_heading->setObjectName(QStringLiteral("controlHeading"));
m_heading->setCursor(Qt::PointingHandCursor);
m_heading->setFont(makeSpacedFont(font(), /*bold*/ true, /*pointSizeDelta*/ 0));
outerLayout->addWidget(m_heading);
// Rows taller than the space available scroll rather than being cut off
// (REQ-UI-CONTROLS-PANEL). The viewport is transparent so the panel's own rounded
// chrome shows through, and horizontal scrolling is off because the width always
// follows the content.
m_rows = new QWidget(this);
m_rowsLayout = new QVBoxLayout(m_rows);
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
m_rowsLayout->setSpacing(0);
m_rows->setAutoFillBackground(false);
m_scrollArea = new QScrollArea(this);
m_scrollArea->setFrameShape(QFrame::NoFrame);
m_scrollArea->setWidgetResizable(true);
m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_scrollArea->viewport()->setAutoFillBackground(false);
m_scrollArea->setWidget(m_rows);
outerLayout->addWidget(m_scrollArea);
// Polling rather than subscribing; see the class comment.
m_refreshTimer = new QTimer(this);
connect(m_refreshTimer, &QTimer::timeout, this, &ControlsPanel::refresh);
m_refreshTimer->start(kRefreshMs);
refresh();
}
void ControlsPanel::invalidateLayout()
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<FloatingLayoutInvalidatedEvent>());
}
void ControlsPanel::mousePressEvent(QMouseEvent* event)
{
// Only the heading toggles; a click anywhere else is swallowed so it never reaches
// the world beneath (REQ-UI-CONTROLS-PANEL).
if (m_heading->geometry().contains(event->pos()))
{
m_collapsed = !m_collapsed;
m_scrollArea->setVisible(!m_collapsed);
invalidateLayout();
}
event->accept();
}
void ControlsPanel::refresh()
{
if (!m_view) { return; }
const ControlContext context = m_view->getControlContext();
const QString heading = getHeadingText(context);
const std::vector<ControlAction> contextActions = getContextActions(context);
const std::vector<ControlAction> alwaysActions = getAlwaysAvailableActions(context);
if (heading == m_shownHeading && contextActions == m_shownContextActions
&& alwaysActions == m_shownAlwaysActions)
{
return;
}
m_shownHeading = heading;
m_shownContextActions = contextActions;
m_shownAlwaysActions = alwaysActions;
rebuild(context);
}
void ControlsPanel::rebuild(const ControlContext& context)
{
m_heading->setText(m_shownHeading);
// Rows are rebuilt wholesale rather than reconciled: a context change replaces
// nearly all of them, and the panel redraws only when something actually changed.
while (QLayoutItem* item = m_rowsLayout->takeAt(0))
{
delete item->widget();
delete item;
}
for (ControlAction action : m_shownContextActions)
{
addAndShow(m_rowsLayout, makeRow(action, context, m_rows));
}
// The always-available block sits under a divider in every context, the General one
// included, so the card is read the same way wherever the player is
// (REQ-UI-CONTROLS-CARD).
if (!m_shownAlwaysActions.empty())
{
QFrame* divider = new QFrame(m_rows);
divider->setFrameShape(QFrame::HLine);
divider->setFrameShadow(QFrame::Plain);
m_rowsLayout->addSpacing(6);
addAndShow(m_rowsLayout, divider);
QLabel* caption = new QLabel(tr("ALWAYS AVAILABLE"), m_rows);
caption->setObjectName(QStringLiteral("controlCaption"));
caption->setFont(makeSpacedFont(font(), /*bold*/ false, /*pointSizeDelta*/ -1));
addAndShow(m_rowsLayout, caption);
}
for (ControlAction action : m_shownAlwaysActions)
{
addAndShow(m_rowsLayout, makeRow(action, context, m_rows));
}
m_scrollArea->setVisible(!m_collapsed);
invalidateLayout();
}
QString ControlsPanel::getHeadingText(const ControlContext& context) const
{
const ControlContextKind kind = getControlContextKind(context);
const QString name = getControlContextName(kind);
QString detail;
switch (kind)
{
case ControlContextKind::Build:
detail = getBuildingTypeName(context.builderType);
break;
case ControlContextKind::Blueprint:
{
// The temporary blueprint is never named (REQ-UI-BLUEPRINT-TEMP), so it is
// labelled by what it is rather than left blank.
const QString blueprintName = m_view->getActiveBlueprintName();
detail = blueprintName.isEmpty() ? tr("Temporary") : blueprintName;
break;
}
case ControlContextKind::Selection:
detail = context.selection == ControlSelection::Buildings
? tr("%n building(s)", "", context.selectionCount)
: tr("%n object(s)", "", context.selectionCount);
break;
case ControlContextKind::General:
case ControlContextKind::Deconstruct:
break;
}
if (detail.isEmpty()) { return name; }
return name + QStringLiteral(" ") + kHeadingSeparator + QStringLiteral(" ") + detail;
}
void ControlsPanel::placeIn(const QRect& viewRect, const std::vector<QRect>& occupiedRects)
{
if (viewRect.isNull()) { return; }
// Rows are torn down and rebuilt wholesale, and a widget added to a layout is only
// shown once that layout runs -- without this the new rows count for nothing and
// the card is measured for the context before it. The polish belongs to the same
// step: a freshly created label reports an unstyled size hint until the stylesheet
// has reached it, and the badge chips carry border and padding that change it.
m_rows->ensurePolished();
m_rowsLayout->invalidate();
m_rowsLayout->activate();
const QRect band = viewRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
if (band.width() <= 0 || band.height() <= 0) { return; }
// Measured from the heading and the rows directly rather than from the panel's own
// layout: the rows now sit in a scroll area, whose size hint describes a viewport
// and says nothing about how tall its contents are. Deliberately not activating the
// panel's own layout either -- that lays its children out inside the geometry left
// over from the previous context, which is the wrong frame of reference for
// choosing the new one. setGeometry below re-runs it.
const int chromePx = 2 * (kCardMarginPx + kBorderPx);
const QSize headingHint = m_heading->sizeHint();
const QSize rowsHint = m_rows->sizeHint();
int contentWidthPx = headingHint.width();
int contentHeightPx = headingHint.height();
if (!m_collapsed)
{
contentWidthPx = qMax(contentWidthPx, rowsHint.width());
contentHeightPx += kHeadingGapPx + rowsHint.height();
}
const int wantedHeightPx = contentHeightPx + chromePx;
// The bottom-left corner of the view, growing upward as rows are added
// (REQ-UI-CONTROLS-PANEL).
int widthPx = qMin(contentWidthPx + chromePx, band.width());
// The build button bar is centered and sized to its buttons, so it usually leaves
// this corner free and the panel can share the bottom edge with it. Only where the
// two would actually meet does the panel rise, clearing the bar's top by the same
// margin it keeps from the view's edges (REQ-UI-BUILD-BAR). The bar is the only
// widget placed before this one, so it is the only rect that can be in the way.
const int bottomPx = getAvailableBottomPx(band, occupiedRects, band.left(),
band.left() + widthPx - 1, kMarginPx);
int heightPx = qMin(wantedHeightPx, qMax(0, bottomPx - band.top() + 1));
int topPx = bottomPx - heightPx + 1;
// Whatever the rows lost to either cap, they scroll for. The scrollbar needs its
// own width, or it would appear over the labels.
if (heightPx < wantedHeightPx)
{
widthPx = qMin(widthPx + m_scrollArea->verticalScrollBar()->sizeHint().width(),
band.width());
}
setGeometry(band.left(), topPx, widthPx, heightPx);
}

View File

@@ -1,87 +0,0 @@
#pragma once
#include <vector>
#include <QRect>
#include <QString>
#include <QWidget>
#include "ControlAction.h"
#include "FloatingPanel.h"
class GameWorldView;
class QLabel;
class QScrollArea;
class QTimer;
class QVBoxLayout;
// Shows the controls available in the player's current situation
// (REQ-UI-CONTROLS-PANEL). The panel decides nothing: which rows apply is
// ControlAction.h's answer, the same one the key handling and the world view's mouse
// dispatch act on, so what is shown and what happens cannot part company
// (REQ-UI-CONTROLS-ACCURACY).
//
// It floats over the game world at the left edge, bottom-aligned within the band its
// owner hands it, and collapses to its heading when the heading is clicked.
//
// Refreshed on a timer rather than by subscribing to events: two of the things that
// change a row -- a belt drag starting, the ghost moving over a transfer target --
// happen on mouse movement and publish nothing, and they still have to be reflected
// while the game is paused, so there is no tick to hang it on either. The rebuild is
// skipped unless the resolved content actually differs, which is a vector of enums to
// compare.
class ControlsPanel : public QWidget, public FloatingPanel
{
Q_OBJECT
public:
// Neither pointer is owned; both must outlive this widget. The view is the source
// of the control context, being the widget that owns the build mode and the
// selection.
ControlsPanel(const GameWorldView* view, QWidget* parent = nullptr);
// Places the panel in the bottom-left corner of the game world view. It shares the
// bottom edge with the build button bar rather than clearing its strip, the bar
// being centered and sized to its buttons and so usually leaving the left free; it
// rises above the bar only when the two would otherwise overlap
// (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR). The bar is the only thing placed before
// it, so it is the only rect it ever has to rise above.
void placeIn(const QRect& viewRect,
const std::vector<QRect>& occupiedRects) override;
protected:
// Clicking the heading collapses and expands the panel (REQ-UI-CONTROLS-PANEL).
void mousePressEvent(QMouseEvent* event) override;
private:
// Re-resolves the context and rebuilds only if the rows or the heading changed.
void refresh();
// Replaces the rows with those of the current context.
void rebuild(const ControlContext& context);
// The heading's "<name> * <detail>" text for the context, detail omitted when the
// context has none.
QString getHeadingText(const ControlContext& context) const;
// Asks for the placement pass to be re-run, the panel's own size having changed.
void invalidateLayout();
const GameWorldView* m_view;
QLabel* m_heading;
// Scrolls the rows once they outgrow the space the panel has (REQ-UI-CONTROLS-PANEL).
// The heading is deliberately outside it, so it stays put and stays clickable.
QScrollArea* m_scrollArea;
QWidget* m_rows;
QVBoxLayout* m_rowsLayout;
QTimer* m_refreshTimer;
// What is currently drawn, so a refresh that resolves to the same thing does
// nothing. The always-available block is kept separately because the divider
// between the two is part of what is drawn.
QString m_shownHeading;
std::vector<ControlAction> m_shownContextActions;
std::vector<ControlAction> m_shownAlwaysActions;
// Survives context changes and simulation restarts; presentation only, never a
// command (REQ-UI-CONTROLS-PANEL).
bool m_collapsed = false;
};

View File

@@ -0,0 +1,403 @@
#include "FieldSelectionPanel.h"
#include <algorithm>
#include <map>
#include <string>
#include <QFont>
#include <QLabel>
#include <QStringList>
#include <QVBoxLayout>
#include "DebrisSystem.h"
#include "DisplayName.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "SelectedBehaviorComponent.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "ThreatCostCalculator.h"
#include "WeaponComponent.h"
FieldSelectionPanel::FieldSelectionPanel(Simulation* sim,
const GameConfig* config,
QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
{
// Zero margins and the same spacing as the enclosing SelectionPanel layout, so
// nesting the field widgets in this panel leaves their geometry unchanged.
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(4);
m_layout->setAlignment(Qt::AlignTop);
m_entityTitleLabel = new QLabel(this);
QFont titleFont = m_entityTitleLabel->font();
titleFont.setBold(true);
m_entityTitleLabel->setFont(titleFont);
m_layout->addWidget(m_entityTitleLabel);
m_entityTitleLabel->hide();
m_entityStatsPanel = new ShipStatsPanel(config, this);
m_layout->addWidget(m_entityStatsPanel);
m_entityStatsPanel->hide();
m_stationStatsLabel = new QLabel(this);
m_stationStatsLabel->setWordWrap(true);
m_layout->addWidget(m_stationStatsLabel);
m_stationStatsLabel->hide();
m_entitySummaryLabel = new QLabel(this);
m_entitySummaryLabel->setWordWrap(true);
m_layout->addWidget(m_entitySummaryLabel);
m_entitySummaryLabel->hide();
m_scrapLabel = new QLabel(this);
m_layout->addWidget(m_scrapLabel);
m_scrapLabel->hide();
hide();
registerForEvents();
}
FieldSelectionPanel::~FieldSelectionPanel()
{
unregisterForEvents();
}
void FieldSelectionPanel::setSelectedEntities(const std::vector<entt::entity>& entities)
{
m_selectedEntities = entities;
rebuild();
}
void FieldSelectionPanel::setSelectedDebris(const std::vector<entt::entity>& debris)
{
m_selectedDebris = debris;
rebuild();
}
void FieldSelectionPanel::clearSelection()
{
m_selectedEntities.clear();
m_selectedDebris.clear();
rebuild();
}
bool FieldSelectionPanel::hasSelection() const
{
return !m_selectedEntities.empty() || !m_selectedDebris.empty();
}
void FieldSelectionPanel::hideAllWidgets()
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
}
void FieldSelectionPanel::rebuild()
{
if (!hasSelection())
{
// Nothing in the field category: take no space, leaving the panel to whatever
// the building category shows (REQ-UI-SELECTION-CATEGORIES).
hideAllWidgets();
hide();
return;
}
show();
EntityAdmin& admin = m_sim->getAdmin();
// A full single-object stats panel is shown only for a lone field object: one actor
// with no debris, or one piece of debris with no actors. As soon as the selection holds
// more than one object (multiple actors, multiple debris, or actors plus debris), the
// panel switches to the compact count summary (REQ-UI-FIELD-MULTI-SELECTION).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
m_entitySummaryLabel->hide();
m_scrapLabel->hide();
const entt::entity entity = m_selectedEntities.front();
if (admin.isValid(entity) && admin.hasAll<ShipIdentityComponent>(entity))
{
buildEntityShip(entity);
}
else if (admin.isValid(entity) && admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
else
{
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
}
return;
}
if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
// Single piece of debris: a "Debris" heading plus a "Scrap" stat row, styled like
// the ship/station stats panels (REQ-UI-DEBRIS-PANEL).
m_entitySummaryLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
buildDebrisSingle();
return;
}
// More than one field object: a compact count summary. buildEntitySummary() appends the
// "Debris x N" and "Scrap x N" lines when debris is part of the selection.
m_entityTitleLabel->hide();
m_entityStatsPanel->hide();
m_stationStatsLabel->hide();
m_scrapLabel->hide();
buildEntitySummary();
}
void FieldSelectionPanel::refreshDisplay()
{
if (!hasSelection()) { return; }
// Keep the live values current: the single-actor stats panel, the single-debris stats
// panel (whose Scrap row shrinks as it is collected), or the count summary (whose Scrap
// line shrinks likewise) — matching the layout chosen by rebuild()
// (REQ-UI-SHIP-STATS-PANEL, REQ-UI-DEBRIS-PANEL).
if (m_selectedEntities.size() == 1 && m_selectedDebris.empty())
{
refreshEntityStats();
}
else if (m_selectedEntities.empty() && m_selectedDebris.size() == 1)
{
buildDebrisSingle();
}
else
{
buildEntitySummary();
}
}
void FieldSelectionPanel::buildDebrisSingle()
{
// "Debris" heading + a single "Scrap" stat row for the piece's remaining amount,
// mirroring the single-actor stats panels (REQ-UI-DEBRIS-PANEL).
m_entityTitleLabel->setText(tr("Debris"));
m_entityTitleLabel->show();
m_scrapLabel->setText(tr("Scrap: %1").arg(selectedDebrisScrapTotal()));
m_scrapLabel->show();
}
void FieldSelectionPanel::buildEntitySummary()
{
EntityAdmin& admin = m_sim->getAdmin();
// Group actors by faction + kind + ship schematic, preserving first-seen order
// (REQ-UI-FIELD-MULTI-SELECTION).
std::vector<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity entity : m_selectedEntities)
{
if (!admin.isValid(entity)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const std::string& id = admin.get<ShipIdentityComponent>(entity).schematicId;
const QString name = QString::fromStdString(toDisplayName(id));
key = (isEnemy ? QStringLiteral("ship:enemy:") : QStringLiteral("ship:player:"))
+ QString::fromStdString(id);
label = isEnemy ? tr("Enemy %1").arg(name) : name;
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
key = isEnemy ? QStringLiteral("station:enemy") : QStringLiteral("station:player");
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
}
else
{
continue;
}
if (counts.find(key) == counts.end())
{
keys.push_back(key);
labels[key] = label;
}
counts[key] += 1;
}
// One "<type> x <count>" line per group (matching the recipe tooltip and the building
// multi-selection). No total-count header, consistent with the building panel. When
// debris is part of the selection, a "Debris x <count>" line followed by a
// "Scrap x <total>" line are appended into the same label so the line spacing is
// uniform (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
QStringList lines;
for (const QString& key : keys)
{
lines << tr("%1 x %2").arg(labels[key]).arg(counts[key]);
}
if (!m_selectedDebris.empty())
{
lines << tr("Debris x %1").arg(static_cast<int>(m_selectedDebris.size()));
lines << scrapTotalText();
}
m_entitySummaryLabel->setText(lines.join('\n'));
m_entitySummaryLabel->show();
}
void FieldSelectionPanel::buildEntityShip(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(entity);
const HealthComponent& health = admin.get<HealthComponent>(entity);
m_entityTitleLabel->setText(tr("Ship: %1")
.arg(QString::fromStdString(identity.schematicId)));
m_entityTitleLabel->show();
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
m_entityStatsPanel->setDebugDrawEnabled(m_debugDraw);
const ShipDef* schematicDef =
m_config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
const double threat = calculateShipThreatCost(
m_config->threatCosts, *m_config, schematicDef->id,
schematicDef->defaultModules);
m_entityStatsPanel->setThreatCost(threat);
}
m_entityStatsPanel->show();
m_stationStatsLabel->hide();
}
void FieldSelectionPanel::buildEntityStation(entt::entity entity)
{
EntityAdmin& admin = m_sim->getAdmin();
const HealthComponent& health = admin.get<HealthComponent>(entity);
const bool isEnemy = admin.hasAll<FactionComponent>(entity)
&& admin.get<FactionComponent>(entity).isEnemy;
m_entityTitleLabel->setText(isEnemy
? tr("Enemy Defence Station")
: tr("Player Defence Station"));
m_entityTitleLabel->show();
float totalDps = 0.0f;
float maxRange = 0.0f;
bool hasWeapons = false;
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner, const WeaponComponent& w)
{
if (owner.owner != entity) { return; }
hasWeapons = true;
totalDps += w.damage * w.fireRateHz;
if (w.range_tiles > maxRange) { maxRange = w.range_tiles; }
});
QString statsText = tr("HP: %1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f));
if (hasWeapons)
{
statsText += tr("\nDPS: %1").arg(QString::number(static_cast<double>(totalDps), 'f', 1));
statsText += tr("\nRange: %1 tiles").arg(QString::number(static_cast<double>(maxRange), 'f', 1));
}
m_stationStatsLabel->setText(statsText);
m_stationStatsLabel->show();
m_entityStatsPanel->hide();
}
void FieldSelectionPanel::refreshEntityStats()
{
// Only the single-actor stats panel needs a live refresh; the multi-actor summary is
// static counts, and GameWorldView prunes dead/despawned actors and re-emits the
// selection (REQ-UI-ENTITY-CLICK-SELECT), so the panel does not mutate it here.
if (m_selectedEntities.size() != 1) { return; }
EntityAdmin& admin = m_sim->getAdmin();
const entt::entity entity = m_selectedEntities.front();
if (!admin.isValid(entity) || !admin.hasAll<HealthComponent>(entity)) { return; }
const HealthComponent& health = admin.get<HealthComponent>(entity);
if (health.hp <= 0.0f) { return; }
if (admin.hasAll<ShipIdentityComponent>(entity))
{
const ShipStats stats = buildShipStatsFromEntity(admin, entity);
m_entityStatsPanel->refreshFromLive(stats, health.hp);
m_entityStatsPanel->setBehavior(
admin.get<SelectedBehaviorComponent>(entity).winner);
}
else if (admin.hasAll<StationBodyComponent>(entity))
{
buildEntityStation(entity);
}
}
int FieldSelectionPanel::selectedDebrisScrapTotal() const
{
// Sum the remaining scrap across the still-living selected debris (REQ-UI-DEBRIS-PANEL).
int total = 0;
for (const DebrisInfo& info : getAllDebrisInfo(m_sim->getAdmin()))
{
if (std::find(m_selectedDebris.begin(), m_selectedDebris.end(), info.entity)
!= m_selectedDebris.end())
{
total += info.amount;
}
}
return total;
}
QString FieldSelectionPanel::scrapTotalText() const
{
return tr("Scrap x %1").arg(selectedDebrisScrapTotal());
}
void FieldSelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{
refreshDisplay();
}
void FieldSelectionPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{
// Player commands are applied by a queued drain, not synchronously. When the game is
// paused no tick advances, so TickAdvancedEvent never fires; refresh here too.
refreshDisplay();
}
void FieldSelectionPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
{
m_debugDraw = event->active;
m_entityStatsPanel->setDebugDrawEnabled(event->active);
}

View File

@@ -0,0 +1,91 @@
#pragma once
#include <vector>
#include <QString>
#include <QWidget>
#include "entt/entity/entity.hpp"
#include "DebugDrawToggledEvent.h"
#include "EventHandler.h"
#include "PlayerCommandsAppliedEvent.h"
#include "TickAdvancedEvent.h"
struct GameConfig;
class Simulation;
class ShipStatsPanel;
class QLabel;
class QVBoxLayout;
// Renders the "field" selection category — ships, defence stations and debris — as either
// a single-object stats panel (ship, station, or debris) or a compact multi-object count
// summary (REQ-UI-SELECTION-CATEGORIES, REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-DEBRIS-PANEL).
//
// The panel owns its own selection state and its own widgets, and nothing else. Which of
// the two selection categories owns the selection panel is arbitrated by the parent
// SelectionPanel: it feeds this panel through setSelectedEntities() /
// setSelectedDebris() / clearSelection() and asks it via hasSelection(). This panel hides
// itself whenever its selection is empty, so an inactive field category takes no space.
class FieldSelectionPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
DebugDrawToggledEvent>
{
Q_OBJECT
public:
FieldSelectionPanel(Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr);
~FieldSelectionPanel() override;
// Replaces the selected actors (ships and defence stations); debris is left alone,
// the two coexist within the field category (REQ-UI-SELECTION-CATEGORIES).
void setSelectedEntities(const std::vector<entt::entity>& entities);
// Replaces the selected debris; the selected actors are left alone.
void setSelectedDebris(const std::vector<entt::entity>& debris);
// Drops the whole field selection — used when the building category takes over.
void clearSelection();
// True while the field category has anything selected, i.e. while this panel owns
// the selection panel's content.
bool hasSelection() const;
private:
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
// Picks the layout for the current selection and shows/hides this panel accordingly.
void rebuild();
// Keeps the live values of the layout chosen by rebuild() current.
void refreshDisplay();
void buildEntityShip(entt::entity entity);
void buildEntityStation(entt::entity entity);
void buildEntitySummary();
void buildDebrisSingle();
void refreshEntityStats();
void hideAllWidgets();
// Summed remaining scrap across the selected debris (REQ-UI-DEBRIS-PANEL).
int selectedDebrisScrapTotal() const;
// "Scrap x N" line for the multi-object summary (REQ-UI-FIELD-MULTI-SELECTION).
QString scrapTotalText() const;
Simulation* m_sim;
const GameConfig* m_config;
bool m_debugDraw = false;
// The selected ships/defence stations. Shares the "field" selection category with
// debris (m_selectedDebris): both can be non-empty at once (REQ-UI-SELECTION-CATEGORIES).
std::vector<entt::entity> m_selectedEntities;
std::vector<entt::entity> m_selectedDebris;
QVBoxLayout* m_layout;
QLabel* m_entityTitleLabel;
ShipStatsPanel* m_entityStatsPanel;
QLabel* m_stationStatsLabel;
QLabel* m_entitySummaryLabel;
// Shows the debris "Scrap" stat row (single selection) — the scrap total for the
// multi-object summary lives in m_entitySummaryLabel instead.
QLabel* m_scrapLabel;
};

View File

@@ -1,27 +0,0 @@
#pragma once
#include <vector>
#include <QRect>
// A widget floating over the game world view (REQ-UI-WORLD-SIZE). MainWindow places all
// of them in one ordered pass -- build button bar, then controls panel, then selection
// panel -- and each places itself into the space the earlier ones have not taken. The
// order is the priority the requirements state: the bar never moves for anyone
// (REQ-UI-BUILD-BAR), the controls panel steps around the bar (REQ-UI-CONTROLS-PANEL),
// and keeping clear of both is the selection panel's job (REQ-UI-SELECTION-PANEL).
//
// A widget never re-places itself, because what it may take depends on the widgets placed
// before it. It instead publishes FloatingLayoutInvalidatedEvent whenever its content or
// its visibility changed, and the owner re-runs the whole pass.
class FloatingPanel
{
public:
virtual ~FloatingPanel() = default;
// Sets this widget's own geometry within viewRect, keeping clear of occupiedRects --
// the geometry of every floating widget already placed in this pass, in the same
// coordinates. A widget with nothing to show hides itself and takes no space.
virtual void placeIn(const QRect& viewRect,
const std::vector<QRect>& occupiedRects) = 0;
};

View File

@@ -1,7 +1,6 @@
#include "GameWorldView.h" #include "GameWorldView.h"
#include "PlacementRules.h" #include "PlacementRules.h"
#include "FactoryQueries.h" #include "FactoryQueries.h"
#include "BlueprintLibrary.h"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -47,8 +46,6 @@
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "PositionComponent.h" #include "PositionComponent.h"
#include "DebrisSystem.h" #include "DebrisSystem.h"
#include "SelectionAnchorChangedEvent.h"
#include "SelectionBounds.h"
#include "SelectionChangedEvent.h" #include "SelectionChangedEvent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ShipSystem.h" #include "ShipSystem.h"
@@ -76,23 +73,6 @@
namespace namespace
{ {
// The action a left-button gesture triggers, with the Ctrl variant falling back to the
// plain one wherever nothing is bound to it. That fallback is what keeps Ctrl+click
// placing a building in builder mode and Ctrl+drag deconstructing an area: the modifier
// only means something where an action claims it (REQ-UI-CONTROLS-CONTENT).
ControlAction resolveLeftGesture(bool controlHeld, bool isDrag,
const ControlContext& context)
{
if (controlHeld)
{
const ControlAction action = resolveMouseAction(
isDrag ? MouseBinding::CtrlLeftDrag : MouseBinding::CtrlLeftClick, context);
if (action != ControlAction::None) { return action; }
}
return resolveMouseAction(isDrag ? MouseBinding::LeftDrag : MouseBinding::LeftClick,
context);
}
// Keep only the filter entries whose item type is currently unlocked // Keep only the filter entries whose item type is currently unlocked
// (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all". // (REQ-LOCK-UI-BLUEPRINT). An empty result means "accept all".
std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter, std::vector<ItemType> filterUnlockedItems(const std::vector<ItemType>& filter,
@@ -1095,7 +1075,7 @@ void GameWorldView::keyPressEvent(QKeyEvent* event)
// Keys are turned into actions and published by the input mapper // Keys are turned into actions and published by the input mapper
// (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so // (REQ-UI-HOTKEYS); this widget reacts to those as an ordinary subscriber, so
// nothing is handled here directly. // nothing is handled here directly.
if (m_inputMapper.handleKeyPress(event, getControlContext())) { return; } if (m_inputMapper.handleKeyPress(event)) { return; }
QOpenGLWidget::keyPressEvent(event); QOpenGLWidget::keyPressEvent(event);
} }
@@ -1107,7 +1087,7 @@ void GameWorldView::keyReleaseEvent(QKeyEvent* event)
QOpenGLWidget::keyReleaseEvent(event); QOpenGLWidget::keyReleaseEvent(event);
return; return;
} }
if (m_inputMapper.handleKeyRelease(event, getControlContext())) { return; } if (m_inputMapper.handleKeyRelease(event)) { return; }
QOpenGLWidget::keyReleaseEvent(event); QOpenGLWidget::keyReleaseEvent(event);
} }
@@ -1121,93 +1101,36 @@ void GameWorldView::focusOutEvent(QFocusEvent* event)
QOpenGLWidget::focusOutEvent(event); QOpenGLWidget::focusOutEvent(event);
} }
void GameWorldView::setBlueprintLibrary(const BlueprintLibrary* library)
{
m_blueprintLibrary = library;
}
ControlContext GameWorldView::getControlContext() const
{
ControlContext context;
context.mode = m_buildMode.getMode();
context.draggingBelt = m_buildMode.isDraggingBelt();
context.hoveredGhostIsTransfer = m_buildMode.isHoveredGhostTransfer();
if (m_buildMode.isBuilderMode()) { context.builderType = m_buildMode.getBuilderType(); }
// Buildings win over field objects, so the two are never both non-empty
// (REQ-UI-SELECTION-CATEGORIES).
const std::vector<BuildingId>& buildings = m_selection.getSelectedBuildings();
const std::vector<entt::entity>& actors = m_selection.getSelectedActors();
const std::vector<entt::entity>& debris = m_selection.getSelectedDebris();
if (!buildings.empty())
{
context.selection = ControlSelection::Buildings;
context.selectionCount = static_cast<int>(buildings.size());
}
else if (!actors.empty() || !debris.empty())
{
context.selection = ControlSelection::FieldObjects;
context.selectionCount = static_cast<int>(actors.size() + debris.size());
}
if (m_blueprintLibrary)
{
context.placeableBuildingSelected = m_blueprintLibrary->getCanCaptureSelection();
context.temporaryBlueprintExists = m_blueprintLibrary->getHasTemporaryBlueprint();
}
return context;
}
QString GameWorldView::getActiveBlueprintName() const
{
if (!m_buildMode.isBlueprintMode()) { return QString(); }
return m_buildMode.getBlueprint().name;
}
void GameWorldView::mousePressEvent(QMouseEvent* event) void GameWorldView::mousePressEvent(QMouseEvent* event)
{ {
const WorldCoordinates coordinates = getCoordinates(); const WorldCoordinates coordinates = getCoordinates();
const ControlContext context = getControlContext();
if (event->button() != Qt::LeftButton) if (event->button() != Qt::LeftButton)
{ {
if (event->button() == Qt::RightButton) if (event->button() == Qt::RightButton)
{ {
switch (resolveMouseAction(MouseBinding::RightClick, context)) if (m_buildMode.isBuilderMode() && m_buildMode.isDraggingBelt())
{ {
case ControlAction::CancelBeltLine: // Cancel the in-progress belt drag without placing anything;
// Drop the in-progress path without placing anything; belt builder // stay in belt builder mode (REQ-BLD-BELT-DRAG).
// mode stays active (REQ-BLD-BELT-DRAG).
m_buildMode.cancelBeltDrag(); m_buildMode.cancelBeltDrag();
break; }
case ControlAction::ExitMode: else if (m_buildMode.getMode() != BuildMode::None)
{
m_buildMode.exitCurrentMode(); m_buildMode.exitCurrentMode();
break;
default:
break;
} }
} }
return; return;
} }
const QPoint tile = coordinates.widgetToTile(event->pos()); const QPoint tile = coordinates.widgetToTile(event->pos());
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0;
// A press begins the click gesture; whether it turns out to be a drag is settled on if (m_buildMode.isBuilderMode())
// release, where the drag binding is resolved instead.
switch (resolveLeftGesture(controlHeld, /*isDrag*/ false, context))
{ {
case ControlAction::Place: if (m_buildMode.getBuilderType() == BuildingType::Belt)
case ControlAction::ApplySettings:
if (m_buildMode.isBlueprintMode())
{ {
placeBlueprintAtTile(tile); // Deferred placement: start the drag and show the path ghost; nothing
} // is placed until release (REQ-BLD-BELT-DRAG).
else if (m_buildMode.getBuilderType() == BuildingType::Belt)
{
// Belts place by dragging, so the press only anchors the path and shows its
// ghost; nothing is placed until release, and a plain click is the one-tile
// case (REQ-BLD-BELT-DRAG).
m_buildMode.beginBeltDrag(tile); m_buildMode.beginBeltDrag(tile);
m_cursorWorldPos = coordinates.widgetToWorld(event->pos()); m_cursorWorldPos = coordinates.widgetToWorld(event->pos());
recomputeBeltDragPath(tile); recomputeBeltDragPath(tile);
@@ -1216,22 +1139,27 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
{ {
placeAtTile(tile); placeAtTile(tile);
} }
break; }
else if (m_buildMode.isBlueprintMode())
case ControlAction::ToggleDeconstruct: {
placeBlueprintAtTile(tile);
}
else if (m_buildMode.isDeconstructMode())
{
// Start a deconstruct box drag; a plain click resolves as a 1x1 box on // Start a deconstruct box drag; a plain click resolves as a 1x1 box on
// release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX). // release (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX).
m_boxSelecting = true; m_boxSelecting = true;
m_boxStartTile = tile; m_boxStartTile = tile;
m_boxCurrentTile = tile; m_boxCurrentTile = tile;
break; }
else
{
const bool ctrl = (event->modifiers() & Qt::ControlModifier) != 0;
case ControlAction::Select:
case ControlAction::AddToSelection:
// Only a click that hit nothing starts a box drag. Starting one on a hit // Only a click that hit nothing starts a box drag. Starting one on a hit
// would re-resolve the same object as a 1x1 box on release and undo the // would re-resolve the same object as a 1x1 box on release and undo the
// click: a Ctrl+click would toggle the building off, then straight back on. // click: a Ctrl+click would toggle the building off, then straight back on.
if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), controlHeld)) if (!selectAtPoint(tile, coordinates.widgetToWorld(event->pos()), ctrl))
{ {
// selectAtPoint has already cleared the selection unless Ctrl is // selectAtPoint has already cleared the selection unless Ctrl is
// preserving it for an additive drag. // preserving it for an additive drag.
@@ -1239,10 +1167,6 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
m_boxStartTile = tile; m_boxStartTile = tile;
m_boxCurrentTile = tile; m_boxCurrentTile = tile;
} }
break;
default:
break;
} }
} }
@@ -1257,7 +1181,6 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); } if (!buildingHit.has_value()) { buildingHit = siteAtTile(tile); }
if (buildingHit.has_value()) if (buildingHit.has_value())
{ {
publishSelectionAnchor(mode, {*buildingHit}, {}, {});
m_selection.selectBuildings({*buildingHit}, mode); m_selection.selectBuildings({*buildingHit}, mode);
return true; return true;
} }
@@ -1265,7 +1188,6 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos); const entt::entity actorHit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
if (actorHit != entt::null) if (actorHit != entt::null)
{ {
publishSelectionAnchor(mode, {}, {actorHit}, {});
m_selection.selectFieldObjects({actorHit}, {}, mode); m_selection.selectFieldObjects({actorHit}, {}, mode);
return true; return true;
} }
@@ -1273,7 +1195,6 @@ bool GameWorldView::selectAtPoint(QPoint tile, QVector2D worldPos, bool additive
const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos); const entt::entity debrisHit = debrisAtWorldPos(m_sim->getAdmin(), worldPos);
if (debrisHit != entt::null) if (debrisHit != entt::null)
{ {
publishSelectionAnchor(mode, {}, {}, {debrisHit});
m_selection.selectFieldObjects({}, {debrisHit}, mode); m_selection.selectFieldObjects({}, {debrisHit}, mode);
return true; return true;
} }
@@ -1294,7 +1215,6 @@ void GameWorldView::selectInBox(bool additive)
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile); buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
if (!boxIds.empty()) if (!boxIds.empty())
{ {
publishSelectionAnchor(mode, boxIds, {}, {});
m_selection.selectBuildings(boxIds, mode); m_selection.selectBuildings(boxIds, mode);
return; return;
} }
@@ -1305,7 +1225,6 @@ void GameWorldView::selectInBox(bool additive)
debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile); debrisInBox(m_sim->getAdmin(), m_boxStartTile, m_boxCurrentTile);
if (!boxActors.empty() || !boxDebris.empty()) if (!boxActors.empty() || !boxDebris.empty())
{ {
publishSelectionAnchor(mode, {}, boxActors, boxDebris);
m_selection.selectFieldObjects(boxActors, boxDebris, mode); m_selection.selectFieldObjects(boxActors, boxDebris, mode);
return; return;
} }
@@ -1314,35 +1233,6 @@ void GameWorldView::selectInBox(bool additive)
if (!additive) { m_selection.clearAll(); } if (!additive) { m_selection.clearAll(); }
} }
void GameWorldView::publishSelectionAnchor(SelectionMode mode,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris)
{
// An additive gesture onto something already selected is growing that selection, not
// starting one, and the panel stays where it was put (REQ-UI-SELECTION-PANEL).
const bool startsSelection =
(mode == SelectionMode::Replace) || (m_selection.getSelectedBuildings().empty()
&& m_selection.getSelectedActors().empty()
&& m_selection.getSelectedDebris().empty());
if (!startsSelection)
{
return;
}
// Screen space, frozen here: the panel is placed against where the selection is at
// this moment and stays there, however far the view scrolls or the objects move
// afterwards.
const QRect anchorRect = getSelectionWidgetRect(*m_sim, getCoordinates(),
buildings, actors, debris);
if (anchorRect.isNull())
{
return;
}
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SelectionAnchorChangedEvent>(anchorRect));
}
void GameWorldView::mouseMoveEvent(QMouseEvent* event) void GameWorldView::mouseMoveEvent(QMouseEvent* event)
{ {
const WorldCoordinates coordinates = getCoordinates(); const WorldCoordinates coordinates = getCoordinates();
@@ -1373,21 +1263,6 @@ void GameWorldView::mouseMoveEvent(QMouseEvent* event)
else if (m_buildMode.isBlueprintMode()) else if (m_buildMode.isBlueprintMode())
{ {
m_buildMode.setBlueprintGhostTile(tile); m_buildMode.setBlueprintGhostTile(tile);
// Resolved here, once, through the same classifier the click and the ghost's
// colour use, and stored on the mode: the controls panel says "Apply settings"
// exactly when clicking would transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a
// single-building blueprint hit-tests the cursor, so only it can be a hovered
// transfer target.
const std::vector<BlueprintBuilding>& buildings =
m_buildMode.getBlueprint().buildings;
bool transfer = false;
if (buildings.size() == 1)
{
transfer = resolveBlueprintGhostHere(buildings.front(), tile).action
== BlueprintGhostAction::Transfer;
}
m_buildMode.setHoveredGhostTransfer(transfer);
} }
else if (m_buildMode.isDeconstructMode()) else if (m_buildMode.isDeconstructMode())
{ {
@@ -1419,11 +1294,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
const std::vector<BuildingId> boxIds = const std::vector<BuildingId> boxIds =
buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile); buildingsInBox(m_sim->getFactoryState(), m_boxStartTile, m_boxCurrentTile);
const bool controlHeld = (event->modifiers() & Qt::ControlModifier) != 0; if (m_buildMode.isDeconstructMode())
const ControlAction dragAction =
resolveLeftGesture(controlHeld, /*isDrag*/ true, getControlContext());
if (dragAction == ControlAction::DeconstructArea)
{ {
const FactoryState& factory = m_sim->getFactoryState(); const FactoryState& factory = m_sim->getFactoryState();
@@ -1485,9 +1356,7 @@ void GameWorldView::mouseReleaseEvent(QMouseEvent* event)
return; return;
} }
// A Ctrl box adds and never deselects, where a plain one replaces selectInBox((event->modifiers() & Qt::ControlModifier) != 0);
// (REQ-UI-MULTI-SELECT).
selectInBox(dragAction == ControlAction::AddAreaToSelection);
} }
} }

View File

@@ -64,7 +64,6 @@
struct Command; struct Command;
struct ParsedReplay; struct ParsedReplay;
class BlueprintLibrary;
class ItemIconCache; class ItemIconCache;
class ReplayPlayer; class ReplayPlayer;
class Simulation; class Simulation;
@@ -103,21 +102,6 @@ public:
void setGameSpeed(double multiplier); void setGameSpeed(double multiplier);
void resetForNewGame(); void resetForNewGame();
// The blueprint library is constructed after this widget, so it arrives by setter.
// Not owned; supplies the two facts about blueprints that the control context needs
// (REQ-UI-CONTROLS-CONTENT).
void setBlueprintLibrary(const BlueprintLibrary* library);
// The player's current situation, as the one snapshot every reader of the control
// table works from: this widget's own mouse dispatch, the input mapper, and the
// controls panel (REQ-UI-CONTROLS-CONTENT). Built here because this widget owns the
// build mode and the selection.
ControlContext getControlContext() const;
// Name of the blueprint being placed, for the controls panel's heading. Empty while
// no blueprint is active and for the unnamed temporary one (REQ-UI-BLUEPRINT-TEMP).
QString getActiveBlueprintName() const;
protected: protected:
void initializeGL() override; void initializeGL() override;
void paintGL() override; void paintGL() override;
@@ -226,15 +210,6 @@ private:
// which is the only case that goes on to start a box drag. // which is the only case that goes on to start a box drag.
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive); bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
void selectInBox(bool additive); void selectInBox(bool additive);
// Publishes where on the screen the selection about to be made sits, so the
// selection panel can be placed beside it (REQ-UI-SELECTION-PANEL). Called with
// what is about to be selected, immediately before selecting it, and publishes
// nothing unless that selection is starting rather than growing.
void publishSelectionAnchor(
SelectionMode mode,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris);
void stepSpeed(int delta); void stepSpeed(int delta);
void placeAtTile(QPoint tile); void placeAtTile(QPoint tile);
@@ -305,8 +280,6 @@ private:
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change // between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
// events. This widget only resolves what was hit. // events. This widget only resolves what was hit.
SelectionController m_selection; SelectionController m_selection;
// Not owned; set after construction, so null until MainWindow has built it.
const BlueprintLibrary* m_blueprintLibrary = nullptr;
bool m_boxSelecting; bool m_boxSelecting;
QPoint m_boxStartTile; QPoint m_boxStartTile;
QPoint m_boxCurrentTile; QPoint m_boxCurrentTile;

View File

@@ -9,7 +9,6 @@
#include "BlueprintSelectionRequestedEvent.h" #include "BlueprintSelectionRequestedEvent.h"
#include "BuildHotkeyPressedEvent.h" #include "BuildHotkeyPressedEvent.h"
#include "BuildingType.h" #include "BuildingType.h"
#include "ControlAction.h"
#include "DebugDrawToggleRequestedEvent.h" #include "DebugDrawToggleRequestedEvent.h"
#include "EscapeMenuRequestedEvent.h" #include "EscapeMenuRequestedEvent.h"
#include "EventManager.h" #include "EventManager.h"
@@ -78,7 +77,7 @@ QString InputMapper::getBuildHotkeyLabel(BuildingType type)
return QString(); return QString();
} }
bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context) bool InputMapper::handleKeyPress(QKeyEvent* event)
{ {
// Auto-repeat says nothing new about which keys are down, and a held action is // Auto-repeat says nothing new about which keys are down, and a held action is
// already held. // already held.
@@ -87,9 +86,6 @@ bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the // Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
// physical digit independent of keyboard layout and Shift (with Shift held, key() // physical digit independent of keyboard layout and Shift (with Shift held, key()
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39. // for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
// Not part of the ControlAction table: these are advertised on the build buttons
// rather than in the controls panel, and getBuildHotkeyLabel already reads the
// same binding table this does (REQ-UI-CONTROLS-ACCURACY).
const quint32 virtualKey = event->nativeVirtualKey(); const quint32 virtualKey = event->nativeVirtualKey();
if (virtualKey >= 0x31 && virtualKey <= 0x39) if (virtualKey >= 0x31 && virtualKey <= 0x39)
{ {
@@ -104,102 +100,107 @@ bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context
} }
} }
// Development controls, deliberately outside the table so they are never offered // Blueprint chords (REQ-UI-HOTKEYS). Checked ahead of the plain-key switch below,
// to the player (REQ-UI-CONTROLS-ACCURACY). // which binds bare A/D/W/S/R/Q/C/V and must not fire on a Ctrl chord -- bare C and V
// are the temporary-blueprint counterparts of these two (REQ-UI-BLUEPRINT-TEMP). 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()) 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:
m_panLeftHeld = true;
updatePanDirection();
return true;
case Qt::Key_D:
m_panRightHeld = true;
updatePanDirection();
return true;
case Qt::Key_Space:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PauseToggleRequestedEvent>());
return true;
case Qt::Key_W:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(+1));
return true;
case Qt::Key_S:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(-1));
return true;
case Qt::Key_R:
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GhostRotationRequestedEvent>(
(event->modifiers() & Qt::ShiftModifier) != 0));
return true;
case Qt::Key_Q:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>());
return true;
case Qt::Key_C:
// Capture a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
// 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<TemporaryBlueprintCaptureRequestedEvent>());
return true;
case Qt::Key_V:
// Re-enter placement mode for the temporary blueprint captured with C, if there is
// one (REQ-UI-BLUEPRINT-TEMP). The library holds it; nothing is captured here.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
return true;
case Qt::Key_F3: case Qt::Key_F3:
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<DebugDrawToggleRequestedEvent>()); std::make_shared<DebugDrawToggleRequestedEvent>());
return true; return true;
case Qt::Key_Escape:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
case Qt::Key_F4: case Qt::Key_F4:
EventManager::getInstance()->addEvent( EventManager::getInstance()->addEvent(
std::make_shared<TracePrintRequestedEvent>()); std::make_shared<TracePrintRequestedEvent>());
return true; return true;
default: default:
break;
}
// Everything else: the key names an action, the action names an event. Which key
// is bound to what, and whether it does anything in this situation, are both the
// table's business -- this switch only knows what each action means
// (REQ-UI-HOTKEYS, REQ-UI-CONTROLS-ACCURACY).
switch (resolveKeyAction(event->key(), event->modifiers(), context))
{
case ControlAction::Move:
// A parameter of the action rather than an action of its own: the table binds
// both keys to Move and the direction is read off the key here, as the rotation
// direction and the build hotkey's digit are.
if (event->key() == Qt::Key_A) { m_panLeftHeld = true; }
else { m_panRightHeld = true; }
updatePanDirection();
return true;
case ControlAction::GameSpeed:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SpeedStepRequestedEvent>(event->key() == Qt::Key_W ? +1 : -1));
return true;
case ControlAction::TogglePause:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<PauseToggleRequestedEvent>());
return true;
case ControlAction::Rotate:
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GhostRotationRequestedEvent>(
(event->modifiers() & Qt::ShiftModifier) != 0));
return true;
case ControlAction::EnterDeconstruct:
case ControlAction::ExitMode:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<ModeCancelRequestedEvent>());
return true;
case ControlAction::CopyTemporary:
// The BlueprintLibrary owns the selection and blueprint-capture logic; it drives
// placement mode from there (REQ-UI-BLUEPRINT-TEMP).
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintCaptureRequestedEvent>());
return true;
case ControlAction::PasteTemporary:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
return true;
case ControlAction::CreateBlueprint:
// Decided by MainWindow, the only widget that can pause the game and dim the
// window for a modal.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSaveRequestedEvent>());
return true;
case ControlAction::OpenBlueprints:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<BlueprintSelectionRequestedEvent>());
return true;
case ControlAction::OpenMenu:
EventManager::getInstance()->sendEventImmediately(
std::make_shared<EscapeMenuRequestedEvent>());
return true;
default:
// Either nothing is bound to the key here, or what is bound is a mouse gesture
// the view handles. Unconsumed, so ordinary Qt shortcuts keep working.
return false; return false;
} }
} }
bool InputMapper::handleKeyRelease(QKeyEvent* event, const ControlContext& context) bool InputMapper::handleKeyRelease(QKeyEvent* event)
{ {
if (event->isAutoRepeat()) { return false; } if (event->isAutoRepeat()) { return false; }
// Only held actions have a release worth acting on. Resolved through the table switch (event->key())
// rather than matched against A and D directly, so the keys stay rebindable in one
// place rather than two.
if (resolveKeyAction(event->key(), event->modifiers(), context) != ControlAction::Move)
{ {
return false; case Qt::Key_A:
} m_panLeftHeld = false;
if (event->key() == Qt::Key_A) { m_panLeftHeld = false; }
else { m_panRightHeld = false; }
updatePanDirection(); updatePanDirection();
return true; return true;
case Qt::Key_D:
m_panRightHeld = false;
updatePanDirection();
return true;
default:
return false;
}
} }
void InputMapper::releaseAll() void InputMapper::releaseAll()

View File

@@ -3,19 +3,14 @@
#include <QString> #include <QString>
#include "BuildingType.h" #include "BuildingType.h"
#include "ControlAction.h"
#include "WorldCamera.h" #include "WorldCamera.h"
class QKeyEvent; class QKeyEvent;
// Turns raw key events into the game's semantic actions and publishes them // Turns raw key events into the game's semantic actions and publishes them
// (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can // (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can
// be rebound independently later. // be rebound independently later; the bindings themselves are still hard-coded
// // here for now.
// Which key means what is not decided here: the caller hands in a ControlContext and
// ControlAction.h resolves the press against it, so this file only knows what each
// action means once resolved. That is what keeps the controls panel and the key
// handling from drifting apart -- both read the one table (REQ-UI-CONTROLS-ACCURACY).
// //
// Two output shapes, chosen by the nature of the action rather than by taste: // Two output shapes, chosen by the nature of the action rather than by taste:
// //
@@ -39,10 +34,9 @@ public:
static QString getBuildHotkeyLabel(BuildingType type); static QString getBuildHotkeyLabel(BuildingType type);
// Both return true when the key was consumed; the caller passes anything else // Both return true when the key was consumed; the caller passes anything else
// on to its base class so unrelated shortcuts keep working. `context` is the // on to its base class so unrelated shortcuts keep working.
// player's current situation, which decides what a key does (REQ-UI-CONTROLS-CONTENT). bool handleKeyPress(QKeyEvent* event);
bool handleKeyPress(QKeyEvent* event, const ControlContext& context); bool handleKeyRelease(QKeyEvent* event);
bool handleKeyRelease(QKeyEvent* event, const ControlContext& context);
// Drops all held-key state, publishing the resulting change. Call when the // Drops all held-key state, publishing the resulting change. Call when the
// receiving widget can no longer expect key-up events. // receiving widget can no longer expect key-up events.

View File

@@ -29,10 +29,8 @@
#include "SchematicChoiceDialog.h" #include "SchematicChoiceDialog.h"
#include "HeaderBar.h" #include "HeaderBar.h"
#include "SelectionPanel.h" #include "SelectionPanel.h"
#include "ControlsPanel.h"
#include "ShipLayoutBlueprintSerializer.h" #include "ShipLayoutBlueprintSerializer.h"
#include "ShipLayoutDialog.h" #include "ShipLayoutDialog.h"
#include "BuildingIconCache.h"
#include "ItemIconCache.h" #include "ItemIconCache.h"
#include "ModalPauseScope.h" #include "ModalPauseScope.h"
#include "Simulation.h" #include "Simulation.h"
@@ -50,52 +48,44 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
setWindowTitle(tr("Dota Factory")); setWindowTitle(tr("Dota Factory"));
resize(1280, 768); resize(1280, 768);
// Item and building icons live alongside the config (siblings of the config dir), // Item icons live alongside the config (a sibling of the config dir), read from
// read from disk at runtime the same way visuals.toml is (REQ-UI-ITEM-ICON, // disk at runtime like the building icons and visuals.toml (REQ-UI-ITEM-ICON).
// REQ-UI-BUILD-ICON). const std::string itemsIconDir = QDir::cleanPath(
const QString configDirPath = QString::fromStdString(m_configDir); QString::fromStdString(m_configDir) + "/../icons/items").toStdString();
m_itemIcons = std::make_unique<ItemIconCache>( m_itemIcons = std::make_unique<ItemIconCache>(
QDir::cleanPath(configDirPath + "/../icons/items")); QString::fromStdString(itemsIconDir));
m_buildingIcons = std::make_unique<BuildingIconCache>(
QDir::cleanPath(configDirPath + "/../icons/buildings"));
m_headerBar = new HeaderBar(sim, &sim->getConfig(), m_itemIcons.get(), this); m_headerBar = new HeaderBar(sim, &sim->getConfig(), m_itemIcons.get(), this);
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir, m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
m_itemIcons.get(), m_replay.get(), this); m_itemIcons.get(), m_replay.get(), this);
// Building icons live alongside the config (a sibling of the config dir), read
// from disk at runtime the same way visuals.toml is.
const std::string iconDir = QDir::cleanPath(
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
// Floats over the game world at its bottom center, sized to its buttons // Floats over the game world at its bottom center, sized to its buttons
// (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so // (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
// building it after the world view puts it above the world and its vignettes, // building it after the world view puts it above the world and its vignettes,
// and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM). // and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM).
// Its geometry comes from layoutPanels(). // Its geometry comes from layoutPanels().
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(), iconDir,
m_buildingIcons.get(), m_itemIcons.get(), m_itemIcons.get(), this);
this);
// The blueprints have no widget of their own: they are saved with Ctrl+C and picked // 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 // 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 // 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. // world view because loading blueprints.toml may put a message box on screen.
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this); m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
// Two facts about blueprints decide what the world view offers the player
// (REQ-UI-CONTROLS-CONTENT); the library is built after the view, so it is handed
// over here rather than passed to the constructor.
m_gameWorldView->setBlueprintLibrary(m_blueprintLibrary.get());
// Floats over the game world at its right edge rather than occupying a column of // Floats over the game world at its right edge rather than occupying a column of
// its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like // its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like
// the build button bar it is a sibling of the world view built after it, which is // the build button bar it is a sibling of the world view built after it, which is
// what puts it above the world and its vignettes and below the dim overlay. It // what puts it above the world and its vignettes and below the dim overlay. It
// brings its own chrome; its geometry comes from layoutPanels(). // brings its own chrome; its geometry comes from layoutPanels().
m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), &m_visuals, m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), this);
m_itemIcons.get(), m_buildingIcons.get(),
this);
// Floats at the world view's opposite edge from the selection panel and reads the
// world view for the player's current situation (REQ-UI-CONTROLS-PANEL). Built
// after the view for the same stacking reason as the panels above it.
m_controlsPanel = new ControlsPanel(m_gameWorldView, this);
// Created last so it stacks above the other children; covers the whole window and // 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). // dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
@@ -172,50 +162,14 @@ void MainWindow::layoutPanels()
const QRect worldRect(0, headerH, totalW, totalH - headerH); const QRect worldRect(0, headerH, totalW, totalH - headerH);
m_headerBar->setGeometry(0, 0, totalW, headerH); m_headerBar->setGeometry(0, 0, totalW, headerH);
m_gameWorldView->setGeometry(worldRect); m_gameWorldView->setGeometry(worldRect);
// Sizes itself to its buttons and centers along the bottom of the world view
// (REQ-UI-BUILD-BAR).
m_buildButtonBar->anchorTo(worldRect);
// The panel confines itself to what the bar leaves free, so the bar never has to
// move for it (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR).
m_selectionPanel->anchorTo(
worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx()));
m_dimOverlay->setGeometry(0, 0, totalW, totalH); m_dimOverlay->setGeometry(0, 0, totalW, totalH);
// The floating widgets are placed in one ordered pass, each into the space the
// earlier ones have not taken (FloatingPanel.h). The order is the priority the
// requirements state: the build button bar takes what it wants and never moves for
// anyone (REQ-UI-BUILD-BAR), the controls panel steps around the bar
// (REQ-UI-CONTROLS-PANEL), and the selection panel keeps clear of both
// (REQ-UI-SELECTION-PANEL). A widget with nothing to show hides itself in placeIn()
// and takes no space.
//
// Re-entry is refused rather than queued: setGeometry() on a widget in the pass can
// reach code that asks for another pass, and the one already running is about to
// produce the same answer.
if (m_layingOut)
{
return;
}
m_layingOut = true;
const std::vector<QWidget*> floatingWidgets = {
m_buildButtonBar, m_controlsPanel, m_selectionPanel };
const std::vector<FloatingPanel*> floatingPanels = {
m_buildButtonBar, m_controlsPanel, m_selectionPanel };
std::vector<QRect> occupiedRects;
for (std::size_t i = 0; i < floatingPanels.size(); ++i)
{
floatingPanels[i]->placeIn(worldRect, occupiedRects);
if (floatingWidgets[i]->isVisible())
{
occupiedRects.push_back(floatingWidgets[i]->geometry());
}
}
m_layingOut = false;
}
void MainWindow::handleEvent(
std::shared_ptr<const FloatingLayoutInvalidatedEvent> /*event*/)
{
// One of the floating widgets changed size or visibility. What each of them may take
// depends on the ones placed before it, so the answer is the whole pass rather than
// that one widget re-placing itself.
layoutPanels();
} }
void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event) void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEvent> event)

View File

@@ -12,7 +12,6 @@
#include "BuildingId.h" #include "BuildingId.h"
#include "EscapeMenuRequestedEvent.h" #include "EscapeMenuRequestedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "FloatingLayoutInvalidatedEvent.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "GameOverEvent.h" #include "GameOverEvent.h"
#include "LayoutDialogRequestedEvent.h" #include "LayoutDialogRequestedEvent.h"
@@ -30,10 +29,8 @@ class Simulation;
class GameWorldView; class GameWorldView;
class HeaderBar; class HeaderBar;
class SelectionPanel; class SelectionPanel;
class ControlsPanel;
class BuildButtonBar; class BuildButtonBar;
class BlueprintLibrary; class BlueprintLibrary;
class BuildingIconCache;
class ItemIconCache; class ItemIconCache;
class QCloseEvent; class QCloseEvent;
class QResizeEvent; class QResizeEvent;
@@ -46,8 +43,7 @@ class MainWindow : public QWidget,
LayoutDialogRequestedEvent, LayoutDialogRequestedEvent,
RecipeSelectionRequestedEvent, RecipeSelectionRequestedEvent,
BlueprintSaveRequestedEvent, BlueprintSaveRequestedEvent,
BlueprintSelectionRequestedEvent, BlueprintSelectionRequestedEvent>
FloatingLayoutInvalidatedEvent>
{ {
Q_OBJECT Q_OBJECT
@@ -69,7 +65,6 @@ private:
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> 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 BlueprintSaveRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override; void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const FloatingLayoutInvalidatedEvent> event) override;
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared // 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 // by every restart path. On success the reloaded visuals are applied to this
@@ -88,8 +83,6 @@ private:
// both callers already hold theirs, which is what keeps the dim continuous when a // 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). // confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM).
void showBlueprintSelectionDialog(); void showBlueprintSelectionDialog();
// Places the widgets floating over the game world view, in one ordered pass
// (FloatingPanel.h). Runs on a resize and on every FloatingLayoutInvalidatedEvent.
void layoutPanels(); void layoutPanels();
private: private:
@@ -99,13 +92,9 @@ private:
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header, // One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
// build bar, world view, and recipe dialog all rasterize the same SVGs. // build bar, world view, and recipe dialog all rasterize the same SVGs.
std::unique_ptr<ItemIconCache> m_itemIcons; std::unique_ptr<ItemIconCache> m_itemIcons;
// Likewise one per-building chip cache (REQ-UI-BUILD-ICON), shared by the build bar
// and the selection panel's card headers (REQ-UI-SELECTION-CARD).
std::unique_ptr<BuildingIconCache> m_buildingIcons;
GameWorldView* m_gameWorldView; GameWorldView* m_gameWorldView;
HeaderBar* m_headerBar; HeaderBar* m_headerBar;
SelectionPanel* m_selectionPanel; SelectionPanel* m_selectionPanel;
ControlsPanel* m_controlsPanel;
BuildButtonBar* m_buildButtonBar; BuildButtonBar* m_buildButtonBar;
// The saved blueprints themselves; they have no widget of their own any more and // 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). // are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
@@ -114,8 +103,4 @@ private:
std::vector<ShipLayoutBlueprint> m_layoutBlueprints; std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
// Set while the placement pass runs, so a widget placed in it cannot start a second
// pass from inside the first.
bool m_layingOut = false;
}; };

View File

@@ -1,115 +0,0 @@
#include "SelectionBounds.h"
#include "Building.h"
#include "DebrisComponent.h"
#include "EntityAdmin.h"
#include "FactoryQueries.h"
#include "PositionComponent.h"
#include "Simulation.h"
#include "StationBodyComponent.h"
#include "WorldCoordinates.h"
#include "WorldPrimitives.h"
namespace
{
// The widget rectangle of a footprint anchored at a tile, both kinds of body being
// described the same way.
QRectF getFootprintWidgetRect(const WorldCoordinates& coordinates, QPoint anchor,
QSize footprint)
{
const QPointF topLeft = coordinates.tileToWidget(anchor);
return QRectF(topLeft.x(), topLeft.y(),
footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
}
} // namespace
std::optional<QRectF> getBuildingWidgetRect(const FactoryState& state,
const WorldCoordinates& coordinates,
BuildingId id)
{
if (const Building* building = findBuilding(state, id))
{
return getFootprintWidgetRect(coordinates, building->anchor, building->footprint);
}
if (const ConstructionSite* site = findSite(state, id))
{
return getFootprintWidgetRect(coordinates, site->anchor, site->footprint);
}
return std::nullopt;
}
std::optional<QRectF> getActorWidgetRect(const EntityAdmin& admin,
const WorldCoordinates& coordinates,
entt::entity actor)
{
if (!admin.isValid(actor))
{
return std::nullopt;
}
if (admin.hasAll<StationBodyComponent>(actor))
{
const StationBodyComponent& body = admin.get<StationBodyComponent>(actor);
return getFootprintWidgetRect(coordinates, body.anchor, body.footprint);
}
if (admin.hasAll<PositionComponent>(actor))
{
// A ship is a triangle about its center; the square its longest extent fits in
// is what the panel is placed beside.
const QPointF center =
coordinates.worldToWidget(admin.get<PositionComponent>(actor).value);
const qreal extent = static_cast<qreal>(getShipForwardExtentPx(coordinates));
return QRectF(center.x() - extent, center.y() - extent,
2.0 * extent, 2.0 * extent);
}
return std::nullopt;
}
std::optional<QRectF> getDebrisWidgetRect(const EntityAdmin& admin,
const WorldCoordinates& coordinates,
entt::entity debris)
{
if (!admin.isValid(debris) || !admin.hasAll<PositionComponent, DebrisComponent>(debris))
{
return std::nullopt;
}
const QPointF center =
coordinates.worldToWidget(admin.get<PositionComponent>(debris).value);
const qreal radius = static_cast<qreal>(getDebrisRadiusPx(coordinates));
return QRectF(center.x() - radius, center.y() - radius, 2.0 * radius, 2.0 * radius);
}
QRect getSelectionWidgetRect(const Simulation& sim, const WorldCoordinates& coordinates,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris)
{
QRectF bounds;
// A null rect unites to nothing of its own, so the first object found sets the box
// and every later one grows it.
auto add = [&bounds](const std::optional<QRectF>& rect)
{
if (rect.has_value())
{
bounds = bounds.isNull() ? *rect : bounds.united(*rect);
}
};
for (BuildingId id : buildings)
{
add(getBuildingWidgetRect(sim.getFactoryState(), coordinates, id));
}
for (entt::entity actor : actors)
{
add(getActorWidgetRect(sim.getAdmin(), coordinates, actor));
}
for (entt::entity piece : debris)
{
add(getDebrisWidgetRect(sim.getAdmin(), coordinates, piece));
}
return bounds.isNull() ? QRect() : bounds.toAlignedRect();
}

View File

@@ -1,48 +0,0 @@
#pragma once
#include <optional>
#include <vector>
#include <QRect>
#include "entt/entity/entity.hpp"
#include "BuildingId.h"
class EntityAdmin;
class Simulation;
class WorldCoordinates;
struct FactoryState;
// Where selectable objects are on the screen. The world renderer draws every selection
// outline from these rectangles, and the selection panel is placed beside the one that
// covers the whole selection (REQ-UI-SELECTION-PANEL).
//
// All of them are in the game world view's own widget coordinates, and all of them are
// only true for the WorldCoordinates they were asked for: the transform is a value built
// per frame or per event, so a rectangle does not survive a scroll or a resize.
// The footprint of a building or of a construction site (REQ-UI-SELECTION-CARD). Null
// when the id names neither, which is what a deconstruction under the caller looks like.
std::optional<QRectF> getBuildingWidgetRect(const FactoryState& state,
const WorldCoordinates& coordinates,
BuildingId id);
// The body of a ship or of a defence station (REQ-UI-ENTITY-CLICK-SELECT): a station's
// footprint, or the square the ship's triangle is drawn in.
std::optional<QRectF> getActorWidgetRect(const EntityAdmin& admin,
const WorldCoordinates& coordinates,
entt::entity actor);
// The circle a piece of debris is drawn as (REQ-UI-DEBRIS-CLICK-SELECT).
std::optional<QRectF> getDebrisWidgetRect(const EntityAdmin& admin,
const WorldCoordinates& coordinates,
entt::entity debris);
// The rectangle covering a whole selection -- one object, or the bounding box of all of
// them (REQ-UI-SELECTION-PANEL). Objects that no longer resolve are skipped; the result
// is null when none of them does.
QRect getSelectionWidgetRect(const Simulation& sim, const WorldCoordinates& coordinates,
const std::vector<BuildingId>& buildings,
const std::vector<entt::entity>& actors,
const std::vector<entt::entity>& debris);

File diff suppressed because it is too large Load Diff

View File

@@ -1,110 +1,151 @@
#pragma once #pragma once
#include <optional>
#include <string>
#include <vector>
#include <QPoint>
#include <QRect> #include <QRect>
#include <QWidget> #include <QWidget>
#include <optional> #include "BeltSystem.h"
#include <vector> #include "Building.h"
#include "BuildingId.h"
#include "DebrisSelectionChangedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectionChangedEvent.h" #include "EntitySelectionChangedEvent.h"
#include "EventHandler.h" #include "EventHandler.h"
#include "FloatingPanel.h" #include "GameConfig.h"
#include "FloatingPanelPlacement.h"
#include "PlayerCommandsAppliedEvent.h" #include "PlayerCommandsAppliedEvent.h"
#include "SelectionAnchorChangedEvent.h" #include "RecipesConfig.h"
#include "DebrisSelectionChangedEvent.h"
#include "SelectionChangedEvent.h" #include "SelectionChangedEvent.h"
#include "ShipLayout.h"
#include "ShipsConfig.h"
#include "Tick.h"
#include "TickAdvancedEvent.h" #include "TickAdvancedEvent.h"
#include "selection/SelectionContentFactory.h"
#include "selection/SelectionContext.h"
struct GameConfig;
struct VisualsConfig;
class BuildingIconCache;
class ItemIconCache;
class SelectionContent;
class Simulation; class Simulation;
class FieldSelectionPanel;
class ShipLayoutPreview;
class QLabel;
class QListWidget;
class QPushButton;
class QScrollArea; class QScrollArea;
class QVBoxLayout; class QVBoxLayout;
// Shows the current selection. The panel itself renders nothing: it arbitrates between // Shows the current selection. The building category (buildings and construction sites)
// the two selection categories (REQ-UI-SELECTION-CATEGORIES), picks the card that fits // is rendered by this panel itself; the field category (ships, defence stations, debris)
// what is selected (REQ-UI-SELECTION-CONTENT), and hosts exactly one of them at a time. // is rendered by the embedded FieldSelectionPanel.
// What each card looks like lives in src/ui/selection/. //
// The two categories are mutually exclusive (REQ-UI-SELECTION-CATEGORIES) and this panel
// is the sole arbiter of which one owns the content: it listens to all three selection
// events, forwards the field ones to the child panel, and drops the losing category's
// content. Neither panel touches the other's widgets.
// //
// The panel floats over the game world view rather than occupying a column of its own // The panel floats over the game world view rather than occupying a column of its own
// (REQ-UI-SELECTION-PANEL): it sizes itself to its card, places itself in what the build // (REQ-UI-SELECTION-PANEL): it sizes itself to its content, anchors to the right edge of
// button bar and the controls panel have left free, and hides itself entirely while // the band its owner hands it, and hides itself entirely while nothing is selected
// nothing is selected (REQ-UI-EMPTY-SELECTION). // (REQ-UI-EMPTY-SELECTION).
class SelectionPanel : public QWidget, class SelectionPanel : public QWidget,
public FloatingPanel,
public CombinedEventHandler<TickAdvancedEvent, public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent, PlayerCommandsAppliedEvent,
EntitySelectionChangedEvent, EntitySelectionChangedEvent,
SelectionChangedEvent, SelectionChangedEvent,
SelectionAnchorChangedEvent, DebrisSelectionChangedEvent>
DebrisSelectionChangedEvent,
DebugDrawToggledEvent>
{ {
Q_OBJECT Q_OBJECT
public: public:
// visuals, itemIcons and buildingIcons are window-wide rendering resources the cards
// draw from; none is owned and all must outlive this widget.
SelectionPanel(Simulation* sim, const GameConfig* config, SelectionPanel(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, ItemIconCache* itemIcons, QWidget* parent = nullptr);
BuildingIconCache* buildingIcons, QWidget* parent = nullptr);
~SelectionPanel() override; ~SelectionPanel() override;
// Sizes the panel to its card and places it beside the selection it describes, in // Confines the panel to the given band of the game world view: it right-aligns
// what the widgets placed before it have left free. Keeping clear of them is entirely // within it and centers vertically in it. The band is the world view less the strip
// this panel's job; neither of them ever moves for it (REQ-UI-SELECTION-PANEL, // the build button bar occupies, so the two never overlap and the bar never has to
// REQ-UI-BUILD-BAR, REQ-UI-CONTROLS-PANEL). // move (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR).
void placeIn(const QRect& viewRect, void anchorTo(const QRect& bandRect);
const std::vector<QRect>& occupiedRects) override;
private: private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionAnchorChangedEvent> event) override;
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override; void handleEvent(std::shared_ptr<const TickAdvancedEvent> event) override;
void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override; void handleEvent(std::shared_ptr<const PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override; void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override;
// Re-reads the live values of the card on screen. When the selection now calls for a private slots:
// different card -- a construction site finishing is the case that matters -- it void onSelectRecipeClicked();
// rebuilds instead, because a card never changes its own shape. void onClearBelt();
void refreshContent(); void onSplitterFilterChanged();
// Replaces the card with the one the current selection calls for.
void rebuildContent();
// Asks for the placement pass to be re-run, the card having changed size or the
// panel having gained or lost its reason to be shown at all.
void invalidateLayout();
SelectionContext m_context; private:
// Read through m_context by the cards that need it, so a toggle reaches the card on // Why the selection display is being refreshed. A periodic tick only needs a
// screen without it having to subscribe to the event itself. // lightweight content update (e.g. a construction site's progress label),
bool m_debugDrawEnabled = false; // whereas an applied player command may have changed the configuration and
// needs a full structural rebuild.
enum class RefreshReason
{
PeriodicTick,
CommandApplied
};
SelectionRequest m_request; void onSelectionChanged(const std::vector<BuildingId>& ids);
ContentKey m_contentKey; // Gives the panel to the field category once it has anything selected.
SelectionContent* m_content = nullptr; void yieldToFieldSelection();
// Shows the panel while either category has a selection and hides it otherwise
// (REQ-UI-EMPTY-SELECTION), re-fitting it to its current content while it is shown.
// Every path that changes the content ends here, because the content is what sizes
// the panel.
void updateVisibility();
// Re-fits the panel to its content within the anchored band.
void refit();
void refreshSelectionDisplay(RefreshReason reason);
void rebuild();
void hideAllWidgets();
void buildEmpty();
void buildSingle(BuildingId id);
void buildMulti(const std::vector<BuildingId>& ids);
void refreshBuffers(const Building* b);
void refreshSiteProgress(const ConstructionSite* s);
void updateShipyardLayoutWidgets(BuildingType type,
const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout);
void buildSplitterFilters(const std::optional<BeltSystem::SplitterInfo>& info);
const RecipeDef* findRecipe(const Building* b) const;
const ShipDef* findShipDef(const std::string& id) const;
std::vector<std::string> getAllItemIds() const;
// Where the current selection was on the screen when it started, in the game world Simulation* m_sim;
// view's coordinates, and which side of it the panel took. Both are frozen for as const GameConfig* m_config;
// long as the selection lasts: the anchor because the panel does not chase a std::vector<BuildingId> m_selectedBuildingIds;
// scrolling view or a moving ship, the side because a card that grows must not flip
// the panel across the object (REQ-UI-SELECTION-PANEL). The side is resolved on the
// first placement after a new anchor, being the first point at which the panel's
// width is known.
QRect m_anchorRect;
std::optional<PanelSide> m_side;
// Scrolls the card once it outgrows the space the panel has (REQ-UI-SELECTION-PANEL). // The band the panel confines itself to, in the coordinates of its parent; null
// The card is a child of m_body, not of the panel itself. // until the owner has anchored it for the first time.
QRect m_bandRect;
// Scrolls the content once it outgrows the band (REQ-UI-SELECTION-PANEL). All the
// content widgets below are children of m_content, not of the panel itself.
QScrollArea* m_scrollArea; QScrollArea* m_scrollArea;
QWidget* m_body; QWidget* m_content;
QVBoxLayout* m_bodyLayout;
QVBoxLayout* m_layout;
QLabel* m_titleLabel;
QPushButton* m_recipeSelectButton;
QPushButton* m_clearBeltBtn;
QLabel* m_filterALabel;
QListWidget* m_filterAList;
QLabel* m_filterBLabel;
QListWidget* m_filterBList;
QLabel* m_buffersLabel;
ShipLayoutPreview* m_layoutPreview;
QPushButton* m_configureLayoutBtn;
std::optional<BuildingId> m_singleBuildingId;
bool m_singleIsSite = false; // selected single entity is a construction site
QPoint m_splitterTile;
std::string m_currentRecipeId;
// Renders the field selection (actors + debris) below the building content
// (REQ-UI-FIELD-MULTI-SELECTION). Hides itself while nothing field-side is selected.
FieldSelectionPanel* m_fieldSelectionPanel;
}; };

View File

@@ -1,14 +1,11 @@
#include "ShipStatsPanel.h" #include "ShipStatsPanel.h"
#include <QLabel>
#include <QString> #include <QString>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "BarRow.h"
#include "GameConfig.h" #include "GameConfig.h"
#include "SectionBox.h"
#include "SelectionNames.h"
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
#include "StatRow.h"
#include "ThreatCostCalculator.h" #include "ThreatCostCalculator.h"
namespace namespace
@@ -19,6 +16,20 @@ QString fmt(float value)
return QString::number(static_cast<double>(value), 'f', 1); return QString::number(static_cast<double>(value), 'f', 1);
} }
QLabel* makeSectionHeader(const QString& text, QWidget* parent)
{
QLabel* label = new QLabel(text, parent);
QFont f = label->font();
f.setBold(true);
label->setFont(f);
return label;
}
QLabel* makeStatLabel(QWidget* parent)
{
return new QLabel(parent);
}
} // namespace } // namespace
@@ -27,150 +38,215 @@ ShipStatsPanel::ShipStatsPanel(const GameConfig* config, QWidget* parent)
, m_config(config) , m_config(config)
{ {
QVBoxLayout* layout = new QVBoxLayout(this); QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(4, 4, 4, 4);
layout->setSpacing(2); layout->setSpacing(2);
layout->setAlignment(Qt::AlignTop); layout->setAlignment(Qt::AlignTop);
// Hull stats -- always visible, except cargo capacity, which only a ship that can // Hull stats always visible.
// carry anything shows (REQ-MOD-UI-STATS-PANEL). m_hpLabel = makeStatLabel(this);
m_hpBar = new BarRow(tr("HP"), this); m_speedLabel = makeStatLabel(this);
m_speedRow = new StatRow(tr("Max speed"), this); m_sensorRangeLabel = makeStatLabel(this);
m_sensorRangeRow = new StatRow(tr("Sensor range"), this); m_mainAccelLabel = makeStatLabel(this);
m_mainAccelRow = new StatRow(tr("Main accel"), this); m_maneuveringAccelLabel = makeStatLabel(this);
m_maneuveringAccelRow = new StatRow(tr("Maneuvering accel"), this); m_angularAccelLabel = makeStatLabel(this);
m_angularAccelRow = new StatRow(tr("Angular accel"), this); m_maxRotSpeedLabel = makeStatLabel(this);
m_maxRotSpeedRow = new StatRow(tr("Max rotation"), this); m_cargoCapacityLabel = makeStatLabel(this);
m_cargoCapacityRow = new StatRow(tr("Cargo capacity"), this); m_cargoCapacityLabel->setVisible(false);
m_cargoCapacityRow->hide();
layout->addWidget(m_hpBar); layout->addWidget(m_hpLabel);
layout->addWidget(m_speedRow); layout->addWidget(m_speedLabel);
layout->addWidget(m_sensorRangeRow); layout->addWidget(m_sensorRangeLabel);
layout->addWidget(m_mainAccelRow); layout->addWidget(m_mainAccelLabel);
layout->addWidget(m_maneuveringAccelRow); layout->addWidget(m_maneuveringAccelLabel);
layout->addWidget(m_angularAccelRow); layout->addWidget(m_angularAccelLabel);
layout->addWidget(m_maxRotSpeedRow); layout->addWidget(m_maxRotSpeedLabel);
layout->addWidget(m_cargoCapacityRow); layout->addWidget(m_cargoCapacityLabel);
// One section per capability module type, each shown only while at least one such // Weapon capability section.
// module is installed (REQ-MOD-UI-STATS-PANEL). m_weaponSection = new QWidget(this);
m_weaponSection = new SectionBox(tr("Weapons"), this); {
m_weaponDpsRow = new StatRow(tr("DPS"), m_weaponSection); QVBoxLayout* sl = new QVBoxLayout(m_weaponSection);
m_weaponRangeRow = new StatRow(tr("Range"), m_weaponSection); sl->setContentsMargins(0, 4, 0, 0);
m_weaponSection->getContentLayout()->addWidget(m_weaponDpsRow); sl->setSpacing(2);
m_weaponSection->getContentLayout()->addWidget(m_weaponRangeRow); sl->addWidget(makeSectionHeader(tr("Weapons"), m_weaponSection));
m_weaponSection->hide(); m_weaponDpsLabel = makeStatLabel(m_weaponSection);
m_weaponRangeLabel = makeStatLabel(m_weaponSection);
sl->addWidget(m_weaponDpsLabel);
sl->addWidget(m_weaponRangeLabel);
}
m_weaponSection->setVisible(false);
layout->addWidget(m_weaponSection); layout->addWidget(m_weaponSection);
m_salvageSection = new SectionBox(tr("Salvage"), this); // Salvage capability section.
m_salvageRateRow = new StatRow(tr("Collection rate"), m_salvageSection); m_salvageSection = new QWidget(this);
m_salvageRangeRow = new StatRow(tr("Range"), m_salvageSection); {
m_salvageSection->getContentLayout()->addWidget(m_salvageRateRow); QVBoxLayout* sl = new QVBoxLayout(m_salvageSection);
m_salvageSection->getContentLayout()->addWidget(m_salvageRangeRow); sl->setContentsMargins(0, 4, 0, 0);
m_salvageSection->hide(); sl->setSpacing(2);
sl->addWidget(makeSectionHeader(tr("Salvage"), m_salvageSection));
m_salvageRateLabel = makeStatLabel(m_salvageSection);
m_salvageRangeLabel = makeStatLabel(m_salvageSection);
sl->addWidget(m_salvageRateLabel);
sl->addWidget(m_salvageRangeLabel);
}
m_salvageSection->setVisible(false);
layout->addWidget(m_salvageSection); layout->addWidget(m_salvageSection);
m_repairSection = new SectionBox(tr("Repair"), this); // Repair capability section.
m_repairRateRow = new StatRow(tr("Repair rate"), m_repairSection); m_repairSection = new QWidget(this);
m_repairRangeRow = new StatRow(tr("Range"), m_repairSection); {
m_repairSection->getContentLayout()->addWidget(m_repairRateRow); QVBoxLayout* sl = new QVBoxLayout(m_repairSection);
m_repairSection->getContentLayout()->addWidget(m_repairRangeRow); sl->setContentsMargins(0, 4, 0, 0);
m_repairSection->hide(); sl->setSpacing(2);
sl->addWidget(makeSectionHeader(tr("Repair"), m_repairSection));
m_repairRateLabel = makeStatLabel(m_repairSection);
m_repairRangeLabel = makeStatLabel(m_repairSection);
sl->addWidget(m_repairRateLabel);
sl->addWidget(m_repairRangeLabel);
}
m_repairSection->setVisible(false);
layout->addWidget(m_repairSection); layout->addWidget(m_repairSection);
// Live entities only; the design preview has no behavior to show // Current behavior — live entities only; hidden in the static design
// (REQ-UI-SHIP-BEHAVIOR). // preview (REQ-UI-SHIP-BEHAVIOR).
m_behaviorRow = new StatRow(tr("Behavior"), this); m_behaviorLabel = makeSectionHeader(QString(), this);
m_behaviorRow->hide(); m_behaviorLabel->setVisible(false);
layout->addWidget(m_behaviorRow); layout->addWidget(m_behaviorLabel);
// Threat cost -- shown only while debug draw is active (REQ-UI-SHIP-STATS-PANEL). // Threat cost — debug-only, initially hidden.
m_threatCostRow = new StatRow(tr("Threat cost"), this); m_threatCostLabel = makeStatLabel(this);
m_threatCostRow->hide(); m_threatCostLabel->setVisible(false);
layout->addWidget(m_threatCostRow); layout->addWidget(m_threatCostLabel);
}
void ShipStatsPanel::setBehavior(BehaviorKind kind) layout->addStretch();
{
const QString label = getBehaviorLabel(kind);
m_behaviorRow->setValue(label);
m_behaviorRow->setVisible(!label.isEmpty());
} }
void ShipStatsPanel::refresh(const std::string& shipId, void ShipStatsPanel::refresh(const std::string& shipId,
const std::vector<PlacedModule>& modules) const std::vector<PlacedModule>& modules)
{ {
const ShipStats stats = calculateShipStats(*m_config, shipId, modules); const ShipStats stats = calculateShipStats(*m_config, shipId, modules);
applyStats(stats, 1.0, QString::number(static_cast<int>(stats.hp + 0.5f))); const QString hpText = tr("HP: %1").arg(static_cast<int>(stats.hp + 0.5f));
applyStats(stats, hpText);
setThreatCost(calculateShipThreatCost(m_config->threatCosts, *m_config, const double threat = calculateShipThreatCost(m_config->threatCosts, *m_config,
shipId, modules)); shipId, modules);
setThreatCost(threat);
// The static design preview has no live behavior to show.
m_behaviorLabel->setVisible(false);
} }
void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp) void ShipStatsPanel::refreshFromLive(const ShipStats& stats, float currentHp)
{ {
const double fraction = (stats.hp > 0.0f) const QString hpText = tr("HP: %1 / %2")
? static_cast<double>(currentHp) / stats.hp
: 0.0;
applyStats(stats, fraction, tr("%1 / %2")
.arg(static_cast<int>(currentHp + 0.5f)) .arg(static_cast<int>(currentHp + 0.5f))
.arg(static_cast<int>(stats.hp + 0.5f))); .arg(static_cast<int>(stats.hp + 0.5f));
applyStats(stats, hpText);
} }
void ShipStatsPanel::applyStats(const ShipStats& stats, double hpFraction, void ShipStatsPanel::applyStats(const ShipStats& stats, const QString& hpText)
const QString& hpText)
{ {
m_hpBar->setValue(hpFraction, hpText); m_hpLabel->setText(hpText);
m_speedRow->setValue(tr("%1 tiles/s").arg(fmt(stats.maxSpeed_tps))); m_speedLabel->setText(
m_sensorRangeRow->setValue(tr("%1 tiles").arg(fmt(stats.sensorRange_tiles))); tr("Max Speed: %1 tiles/s").arg(fmt(stats.maxSpeed_tps)));
m_mainAccelRow->setValue( m_sensorRangeLabel->setText(
tr("%1 tiles/s\xc2\xb2").arg(fmt(stats.mainAcceleration_tpss))); tr("Sensor Range: %1 tiles").arg(fmt(stats.sensorRange_tiles)));
m_maneuveringAccelRow->setValue( m_mainAccelLabel->setText(
tr("%1 tiles/s\xc2\xb2").arg(fmt(stats.maneuveringAcceleration_tpss))); tr("Main Accel: %1 tiles/s\xc2\xb2").arg(fmt(stats.mainAcceleration_tpss)));
m_angularAccelRow->setValue( m_maneuveringAccelLabel->setText(
tr("%1 rad/s\xc2\xb2").arg(fmt(stats.angularAcceleration_radpss))); tr("Maneuvering Accel: %1 tiles/s\xc2\xb2").arg(fmt(stats.maneuveringAcceleration_tpss)));
m_maxRotSpeedRow->setValue(tr("%1 rad/s").arg(fmt(stats.maxRotationSpeed_radps))); m_angularAccelLabel->setText(
tr("Angular Accel: %1 rad/s\xc2\xb2").arg(fmt(stats.angularAcceleration_radpss)));
m_maxRotSpeedLabel->setText(
tr("Max Rotation: %1 rad/s").arg(fmt(stats.maxRotationSpeed_radps)));
// Cargo capacity is shown only when the ship can actually hold cargo // Cargo capacity is shown only when the ship can actually hold cargo
// (REQ-MOD-UI-STATS-PANEL). // (REQ-MOD-UI-STATS-PANEL).
m_cargoCapacityRow->setVisible(stats.cargoCapacity > 0);
if (stats.cargoCapacity > 0) if (stats.cargoCapacity > 0)
{ {
m_cargoCapacityRow->setValue(QString::number(stats.cargoCapacity)); m_cargoCapacityLabel->setText(
tr("Cargo Capacity: %1").arg(stats.cargoCapacity));
m_cargoCapacityLabel->setVisible(true);
}
else
{
m_cargoCapacityLabel->setVisible(false);
} }
m_weaponSection->setVisible(stats.weapons.has_value());
if (stats.weapons.has_value()) if (stats.weapons.has_value())
{ {
m_weaponDpsRow->setValue(fmt(stats.weapons->combinedDps)); m_weaponDpsLabel->setText(
m_weaponRangeRow->setValue(tr("%1 tiles").arg(fmt(stats.weapons->maxRange_tiles))); tr("DPS: %1").arg(fmt(stats.weapons->combinedDps)));
m_weaponRangeLabel->setText(
tr("Range: %1 tiles").arg(fmt(stats.weapons->maxRange_tiles)));
m_weaponSection->setVisible(true);
}
else
{
m_weaponSection->setVisible(false);
} }
m_salvageSection->setVisible(stats.salvage.has_value());
if (stats.salvage.has_value()) if (stats.salvage.has_value())
{ {
m_salvageRateRow->setValue( m_salvageRateLabel->setText(
tr("%1 /s").arg(fmt(stats.salvage->combinedCollectionRate))); tr("Collection Rate: %1/s").arg(fmt(stats.salvage->combinedCollectionRate)));
m_salvageRangeRow->setValue(tr("%1 tiles").arg(fmt(stats.salvage->maxRange_tiles))); m_salvageRangeLabel->setText(
tr("Range: %1 tiles").arg(fmt(stats.salvage->maxRange_tiles)));
m_salvageSection->setVisible(true);
}
else
{
m_salvageSection->setVisible(false);
} }
m_repairSection->setVisible(stats.repair.has_value());
if (stats.repair.has_value()) if (stats.repair.has_value())
{ {
m_repairRateRow->setValue( m_repairRateLabel->setText(
tr("%1 HP/s").arg(fmt(stats.repair->combinedRepairRate_hps))); tr("Repair Rate: %1 HP/s").arg(fmt(stats.repair->combinedRepairRate_hps)));
m_repairRangeRow->setValue(tr("%1 tiles").arg(fmt(stats.repair->maxRange_tiles))); m_repairRangeLabel->setText(
tr("Range: %1 tiles").arg(fmt(stats.repair->maxRange_tiles)));
m_repairSection->setVisible(true);
} }
else
{
m_repairSection->setVisible(false);
}
}
void ShipStatsPanel::setBehavior(BehaviorKind kind)
{
QString label;
switch (kind)
{
case BehaviorKind::Retreat: label = tr("Retreating"); break;
case BehaviorKind::Attack: label = tr("Engaging"); break;
case BehaviorKind::SalvageScrap:
case BehaviorKind::DeliverScrap: label = tr("Salvaging"); break;
case BehaviorKind::Repair: label = tr("Repairing"); break;
case BehaviorKind::Rally: label = tr("Rallying"); break;
case BehaviorKind::Standby: label = tr("Standby"); break;
case BehaviorKind::Advance: label = tr("Advancing"); break;
case BehaviorKind::None: break;
}
if (label.isEmpty())
{
m_behaviorLabel->setVisible(false);
return;
}
m_behaviorLabel->setText(tr("Behavior: %1").arg(label));
m_behaviorLabel->setVisible(true);
} }
void ShipStatsPanel::setThreatCost(double cost) void ShipStatsPanel::setThreatCost(double cost)
{ {
m_threatCostRow->setValue(QString::number(cost, 'f', 1)); m_threatCostLabel->setText(tr("Threat Cost: %1").arg(cost, 0, 'f', 1));
m_threatCostRow->setVisible(m_debugDraw); m_threatCostLabel->setVisible(m_debugDraw);
} }
void ShipStatsPanel::setDebugDrawEnabled(bool enabled) void ShipStatsPanel::setDebugDrawEnabled(bool enabled)
{ {
m_debugDraw = enabled; m_debugDraw = enabled;
m_threatCostRow->setVisible(m_debugDraw); m_threatCostLabel->setVisible(m_debugDraw);
} }

View File

@@ -1,28 +1,18 @@
#pragma once #pragma once
#include <map>
#include <string> #include <string>
#include <vector> #include <vector>
#include <QWidget> #include <QWidget>
#include "BehaviorKind.h"
#include "ShipLayout.h" #include "ShipLayout.h"
#include "ShipStatsCalculator.h" #include "ShipStatsCalculator.h"
#include "BehaviorKind.h"
struct GameConfig; struct GameConfig;
class BarRow; class QLabel;
class SectionBox;
class StatRow;
// The hull stats and capability module summaries of one ship, as a bar for HP and a
// label/value row for everything else. Shared by the live selection card
// (REQ-UI-SHIP-STATS-PANEL), the layout configuration dialog's design preview
// (REQ-MOD-UI-STATS-PANEL) and the balancing tool, so all three read alike.
//
// The behavior row is for consumers with no header to put it in. The selection card
// shows the behavior in its header instead (REQ-UI-SHIP-BEHAVIOR) and leaves the row
// unset; the design preview has no live ship to have a behavior at all.
class ShipStatsPanel : public QWidget class ShipStatsPanel : public QWidget
{ {
Q_OBJECT Q_OBJECT
@@ -30,47 +20,44 @@ class ShipStatsPanel : public QWidget
public: public:
explicit ShipStatsPanel(const GameConfig* config, QWidget* parent = nullptr); explicit ShipStatsPanel(const GameConfig* config, QWidget* parent = nullptr);
// Stats of a design rather than of a live ship: the HP bar reads full, because the
// number shown is the maximum the design would have.
void refresh(const std::string& shipId, void refresh(const std::string& shipId,
const std::vector<PlacedModule>& modules); const std::vector<PlacedModule>& modules);
void refreshFromLive(const ShipStats& stats, float currentHp); void refreshFromLive(const ShipStats& stats, float currentHp);
// Shows the ship's top-priority behavior as a row of its own. Never called by the // Displays the ship's current top-priority behavior (REQ-UI-SHIP-BEHAVIOR).
// selection card, which has a header slot for it.
void setBehavior(BehaviorKind kind); void setBehavior(BehaviorKind kind);
void setThreatCost(double cost); void setThreatCost(double cost);
void setDebugDrawEnabled(bool enabled); void setDebugDrawEnabled(bool enabled);
private: private:
void applyStats(const ShipStats& stats, double hpFraction, const QString& hpText); void applyStats(const ShipStats& stats, const QString& hpText);
const GameConfig* m_config; const GameConfig* m_config;
bool m_debugDraw = false; bool m_debugDraw = false;
BarRow* m_hpBar; QLabel* m_behaviorLabel;
StatRow* m_speedRow; QLabel* m_hpLabel;
StatRow* m_sensorRangeRow; QLabel* m_speedLabel;
StatRow* m_mainAccelRow; QLabel* m_sensorRangeLabel;
StatRow* m_maneuveringAccelRow; QLabel* m_mainAccelLabel;
StatRow* m_angularAccelRow; QLabel* m_maneuveringAccelLabel;
StatRow* m_maxRotSpeedRow; QLabel* m_angularAccelLabel;
StatRow* m_cargoCapacityRow; QLabel* m_maxRotSpeedLabel;
QLabel* m_cargoCapacityLabel;
SectionBox* m_weaponSection; QWidget* m_weaponSection;
StatRow* m_weaponDpsRow; QLabel* m_weaponDpsLabel;
StatRow* m_weaponRangeRow; QLabel* m_weaponRangeLabel;
SectionBox* m_salvageSection; QWidget* m_salvageSection;
StatRow* m_salvageRateRow; QLabel* m_salvageRateLabel;
StatRow* m_salvageRangeRow; QLabel* m_salvageRangeLabel;
SectionBox* m_repairSection; QWidget* m_repairSection;
StatRow* m_repairRateRow; QLabel* m_repairRateLabel;
StatRow* m_repairRangeRow; QLabel* m_repairRangeLabel;
StatRow* m_behaviorRow; QLabel* m_threatCostLabel;
StatRow* m_threatCostRow;
}; };

View File

@@ -35,7 +35,6 @@
#include "ProductionRules.h" #include "ProductionRules.h"
#include "RepairBehavior.h" #include "RepairBehavior.h"
#include "SalvageScrapBehavior.h" #include "SalvageScrapBehavior.h"
#include "SelectionBounds.h"
#include "SensorRangeComponent.h" #include "SensorRangeComponent.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "Simulation.h" #include "Simulation.h"
@@ -109,7 +108,7 @@ QColor statusLightFill(ProductionStatus status, const StatusLightVisuals& sl)
} // namespace } // namespace
WorldRenderer::WorldRenderer(const Simulation& sim, const VisualsConfig& visuals, WorldRenderer::WorldRenderer(Simulation& sim, const VisualsConfig& visuals,
ItemIconCache* itemIcons, const std::string& configDir) ItemIconCache* itemIcons, const std::string& configDir)
: m_sim(sim) : m_sim(sim)
, m_visuals(visuals) , m_visuals(visuals)
@@ -452,6 +451,30 @@ void WorldRenderer::drawBuildings(QPainter& painter, const WorldCoordinates& coo
drawSelectionHighlights(painter, coordinates, frame); drawSelectionHighlights(painter, coordinates, frame);
} }
std::optional<QRectF> WorldRenderer::footprintWidgetRect(
const WorldCoordinates& coordinates, BuildingId id) const
{
std::optional<QPoint> anchor;
std::optional<QSize> footprint;
if (const Building* b = findBuilding(m_sim.getFactoryState(), id))
{
anchor = b->anchor;
footprint = b->footprint;
}
else if (const ConstructionSite* s = findSite(m_sim.getFactoryState(), id))
{
anchor = s->anchor;
footprint = s->footprint;
}
if (!anchor.has_value() || !footprint.has_value()) { return std::nullopt; }
const QPointF tl = coordinates.tileToWidget(*anchor);
return QRectF(tl.x(), tl.y(),
footprint->width() * static_cast<qreal>(coordinates.getTilePx()),
footprint->height() * static_cast<qreal>(coordinates.getTilePx()));
}
void WorldRenderer::drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates, void WorldRenderer::drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates,
const WorldRenderFrame& frame) const WorldRenderFrame& frame)
{ {
@@ -460,8 +483,7 @@ void WorldRenderer::drawSelectionHighlights(QPainter& painter, const WorldCoordi
for (BuildingId selId : frame.selection.getSelectedBuildings()) for (BuildingId selId : frame.selection.getSelectedBuildings())
{ {
const std::optional<QRectF> rect = const std::optional<QRectF> rect = footprintWidgetRect(coordinates, selId);
getBuildingWidgetRect(m_sim.getFactoryState(), coordinates, selId);
if (!rect.has_value()) { continue; } if (!rect.has_value()) { continue; }
// Outline sits 1px outside the footprint (into adjacent tiles). // Outline sits 1px outside the footprint (into adjacent tiles).
painter.drawRect(rect->adjusted(-1, -1, 1, 1)); painter.drawRect(rect->adjusted(-1, -1, 1, 1));

View File

@@ -77,7 +77,7 @@ public:
// `itemIcons` is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not // `itemIcons` is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not
// owned, must outlive this renderer. `configDir` is used once, to load the // owned, must outlive this renderer. `configDir` is used once, to load the
// per-building world icons. // per-building world icons.
WorldRenderer(const Simulation& sim, const VisualsConfig& visuals, WorldRenderer(Simulation& sim, const VisualsConfig& visuals,
ItemIconCache* itemIcons, const std::string& configDir); ItemIconCache* itemIcons, const std::string& configDir);
~WorldRenderer(); ~WorldRenderer();
@@ -139,10 +139,16 @@ private:
BuildingType type, const QRectF& box, BuildingType type, const QRectF& box,
const QColor& fill) const; const QColor& fill) const;
// Widget-space rectangle covering a building or construction site's footprint,
// or nullopt if the id resolves to neither. Used by the selection highlight.
std::optional<QRectF> footprintWidgetRect(const WorldCoordinates& coordinates,
BuildingId id) const;
std::optional<QVector2D> entityPosition(entt::entity entity) const; std::optional<QVector2D> entityPosition(entt::entity entity) const;
// The renderer reads the simulation and never writes it. // Non-const only because EntityAdmin's component accessors are; the renderer
const Simulation& m_sim; // reads the simulation and never writes it.
Simulation& m_sim;
const VisualsConfig& m_visuals; const VisualsConfig& m_visuals;
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by // Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by

View File

@@ -1,75 +0,0 @@
#include "AutoProductionContent.h"
#include "Building.h"
#include "BuildingTarget.h"
#include "GameConfig.h"
#include "ProductionRules.h"
AutoProductionContent::AutoProductionContent(const SelectionContext& context,
const SelectionRequest& request,
QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
}
BufferedBuildingContent::CycleInfo AutoProductionContent::getCycleInfo(
const BuildingTarget& target) const
{
CycleInfo info;
// An auto-recipe building always runs an implicit recipe (REQ-BLD-SMELTER,
// REQ-BLD-REPROCESSING), so its production section is always shown -- but only a
// running cycle names a recipe, so while it is idle there is no cycle to describe.
info.runsProduction = true;
if (target.building)
{
// What the building handles at all, so its buffers are not blank whenever it
// happens to be between cycles (REQ-UI-SINGLE-SELECTION). This is the same union
// of every recipe of its type that the simulation sized the buffers over, and
// the locked ones among them are dropped when the card lists them.
for (const RecipeDef* recipe :
gatherCandidateRecipes(*getContext().config, *target.building))
{
for (const RecipeIngredient& ingredient : recipe->inputs)
{
info.handledInputs.push_back(ingredient.item);
}
for (const RecipeOutput& output : recipe->outputs)
{
info.handledOutputs.push_back(output.item);
}
}
}
// Which recipe describes the cycle: the one running, or -- between cycles -- the one
// that ran last. Dropping it while idle would take the summary row and the chips'
// per-cycle amounts away and bring them back with every cycle, resizing the card in
// step with the building's status (REQ-UI-RECIPE-SUMMARY). Only a building that has
// never run has nothing to describe.
if (target.building && target.building->production.has_value())
{
m_lastRecipeId = target.building->production->recipeId;
}
if (!target.building || m_lastRecipeId.empty())
{
return info;
}
const RecipeDef* recipe =
getContext().config->recipes.findRecipeDef(m_lastRecipeId, target.type);
if (!recipe)
{
return info;
}
for (const RecipeIngredient& ingredient : recipe->inputs)
{
info.perCycleInputs[ingredient.item] = ingredient.amount;
}
for (const RecipeOutput& output : recipe->outputs)
{
info.perCycleOutputs[output.item] = output.amount;
}
info.durationSeconds = recipe->durationSeconds;
return info;
}

View File

@@ -1,29 +0,0 @@
#pragma once
#include <string>
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
// The card for a Smelter or a Reprocessing Plant (REQ-UI-SELECTION-CONTENT). Both
// auto-process whatever they receive and have no player-facing recipe selection
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING), so the card has no configuration group at
// all; its cycle is whichever recipe is in production, or the last one that was.
class AutoProductionContent : public BufferedBuildingContent
{
Q_OBJECT
public:
AutoProductionContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent = nullptr);
protected:
CycleInfo getCycleInfo(const BuildingTarget& target) const override;
private:
// The recipe last seen in production, which keeps describing the cycle while the
// building sits between cycles (REQ-UI-RECIPE-SUMMARY). Mutable because it is a
// record of what getCycleInfo() has observed rather than state of its own: the card
// shows the same thing whether or not it has been asked before.
mutable std::string m_lastRecipeId;
};

View File

@@ -1,123 +0,0 @@
#include "BarRow.h"
#include <algorithm>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPaintEvent>
#include <QVBoxLayout>
namespace
{
// Height and corner radius of the fill bar, in device-independent pixels.
const int kBarHeightPx = 6;
const qreal kBarRadiusPx = 3.0;
// Opacity of the unfilled track, over the card's background.
const int kTrackAlpha = 60;
} // namespace
// The bar itself. Painted rather than assembled from a QProgressBar, because all it
// needs is a rounded track with a rounded fill and a style sheet cannot be relied on to
// leave a progress bar's groove and chunk alone across styles.
class BarRow::Bar : public QWidget
{
public:
explicit Bar(QWidget* parent)
: QWidget(parent)
, m_fraction(0.0)
{
setFixedHeight(kBarHeightPx);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
}
void setFraction(double fraction)
{
m_fraction = std::max(0.0, std::min(1.0, fraction));
update();
}
void setFillColor(const QColor& color)
{
m_fillColor = color;
update();
}
protected:
void paintEvent(QPaintEvent* /*event*/) override
{
// The active group is asked for by name rather than taken from the current one,
// which follows the window's focus: the inactive group's highlight is close
// enough to the card's background that the bar reads as gone whenever the game
// is tabbed away from or a modal dialog holds focus -- exactly when a player
// watching a build finish is most likely to be looking at it.
const QColor fill = m_fillColor.isValid()
? m_fillColor
: palette().color(QPalette::Active, QPalette::Highlight);
QColor track = fill;
track.setAlpha(kTrackAlpha);
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setPen(Qt::NoPen);
painter.setBrush(track);
painter.drawRoundedRect(rect(), kBarRadiusPx, kBarRadiusPx);
if (m_fraction > 0.0)
{
QRect filled = rect();
filled.setWidth(static_cast<int>(filled.width() * m_fraction));
painter.setBrush(fill);
painter.drawRoundedRect(filled, kBarRadiusPx, kBarRadiusPx);
}
}
private:
double m_fraction;
QColor m_fillColor;
};
BarRow::BarRow(const QString& caption, QWidget* parent)
: QWidget(parent)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(2);
QWidget* captionRow = new QWidget(this);
QHBoxLayout* captionLayout = new QHBoxLayout(captionRow);
captionLayout->setContentsMargins(0, 0, 0, 0);
captionLayout->setSpacing(8);
m_captionLabel = new QLabel(caption, captionRow);
m_captionLabel->setVisible(!caption.isEmpty());
m_valueLabel = new QLabel(captionRow);
m_valueLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
captionLayout->addWidget(m_captionLabel);
captionLayout->addStretch(1);
captionLayout->addWidget(m_valueLabel);
m_bar = new Bar(this);
layout->addWidget(captionRow);
layout->addWidget(m_bar);
}
void BarRow::setValue(double fraction, const QString& valueText)
{
m_bar->setFraction(fraction);
m_valueLabel->setText(valueText);
}
void BarRow::setFillColor(const QColor& color)
{
m_bar->setFillColor(color);
}

View File

@@ -1,36 +0,0 @@
#pragma once
#include <QColor>
#include <QString>
#include <QWidget>
class QLabel;
// A caption with its value hard right and a horizontal fill bar beneath it. One part for
// the three things the panel shows as a proportion: a construction site's progress, a
// building's production cycle, and the HP of a ship, a station or the HQ
// (REQ-UI-PRODUCTION-PROGRESS, REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL,
// REQ-UI-HQ-PANEL).
//
// The caption may be left empty, for a bar whose meaning is already given by the section
// it sits in.
class BarRow : public QWidget
{
Q_OBJECT
public:
explicit BarRow(const QString& caption, QWidget* parent = nullptr);
// fraction is clamped to [0, 1]; valueText is shown beside the caption as-is, so a
// bar can read "72%" or "340 / 500" as its meaning requires.
void setValue(double fraction, const QString& valueText);
// Overrides the fill color, which defaults to the palette's highlight.
void setFillColor(const QColor& color);
private:
class Bar;
QLabel* m_captionLabel;
QLabel* m_valueLabel;
Bar* m_bar;
};

View File

@@ -1,38 +0,0 @@
#include "BeltContent.h"
#include <QVBoxLayout>
#include "BuildingTarget.h"
#include "ClearBeltControl.h"
#include "SelectionNames.h"
BeltContent::BeltContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
// Only an operational tile aggregates, so a card showing several is never a site;
// a single selected tile still can be one.
: SelectionContent(context,
request.buildings.size() == 1
? asConstructionSite(context, request.buildings.front())
: std::nullopt,
parent)
, m_ids(request.buildings)
{
getRuntimeLayout()->addWidget(new ClearBeltControl(context, m_ids, this));
}
void BeltContent::refreshConfiguration()
{
const BuildingTarget target = resolveBuildingTarget(getContext(), m_ids.front());
if (!target.isValid())
{
return;
}
// An aggregated selection may mix belts with tunnel ends, so it is named after the
// first tile; the count says how many are held (REQ-UI-SELECTION-AGGREGATE).
setBuildingIdentity(target.type, getBuildingTypeName(target.type));
if (m_ids.size() > 1)
{
setCountSlot(static_cast<int>(m_ids.size()));
}
}

View File

@@ -1,30 +0,0 @@
#pragma once
#include <vector>
#include "BuildingId.h"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
// The card for a belt, a tunnel entry or a tunnel exit
// (REQ-UI-SELECTION-CONTENT). These carry no configuration and no buffers of their own;
// their card is the clear action alone (REQ-UI-BELT-CLEAR).
//
// Because that action already operates on the whole selection, several of them aggregate
// into this one card with the count in the header (REQ-UI-SELECTION-AGGREGATE) -- the
// splitter is not among them, as its output filters are per-object.
class BeltContent : public SelectionContent
{
Q_OBJECT
public:
BeltContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshConfiguration() override;
void refreshRuntime() override {}
private:
std::vector<BuildingId> m_ids;
};

View File

@@ -1,209 +0,0 @@
#include "BufferedBuildingContent.h"
#include <map>
#include <set>
#include <QVBoxLayout>
#include "Building.h"
#include "BuildingTarget.h"
#include "DisplayName.h"
#include "FactoryQueries.h"
#include "ProductionSection.h"
#include "RecipeSummaryRow.h"
#include "SectionBox.h"
#include "SelectionNames.h"
#include "Simulation.h"
namespace
{
std::vector<RecipeSummaryRow::Amount> toAmounts(const std::map<std::string, int>& map)
{
std::vector<RecipeSummaryRow::Amount> amounts;
amounts.reserve(map.size());
for (const std::pair<const std::string, int>& entry : map)
{
amounts.push_back(RecipeSummaryRow::Amount{ entry.first, entry.second });
}
return amounts;
}
// Every item one side of the card should list: what the buffer holds, what a cycle
// moves, and what the building handles at all. A building's buffers can carry items it
// is not currently making anything of, and an auto-recipe building between cycles names
// nothing at all, so the three sources are unioned rather than one being picked.
std::set<std::string> collectItemIds(const std::map<std::string, int>& buffered,
const std::map<std::string, int>& perCycle,
const std::vector<std::string>& handled)
{
std::set<std::string> itemIds;
for (const std::pair<const std::string, int>& entry : buffered)
{
itemIds.insert(entry.first);
}
for (const std::pair<const std::string, int>& entry : perCycle)
{
itemIds.insert(entry.first);
}
itemIds.insert(handled.begin(), handled.end());
return itemIds;
}
int lookUp(const std::map<std::string, int>& map, const std::string& key)
{
const std::map<std::string, int>::const_iterator it = map.find(key);
return (it != map.end()) ? it->second : 0;
}
} // namespace
BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context,
BuildingId id, QWidget* parent)
: SelectionContent(context, asConstructionSite(context, id), parent)
, m_id(id)
{
// The summary is configuration -- what the building will do -- so it sits with the
// selection control and is shown for a construction site too (REQ-UI-RECIPE-SUMMARY).
m_recipeSummary = new RecipeSummaryRow(context.itemIcons, this);
getConfigurationLayout()->addWidget(m_recipeSummary);
m_inputSection = new SectionBox(tr("Input buffers"), this);
m_inputChips = new ItemChipRow(context.itemIcons, m_inputSection);
m_inputSection->getContentLayout()->addWidget(m_inputChips);
m_production = new ProductionSection(this);
m_outputSection = new SectionBox(tr("Output buffer"), this);
m_outputChips = new ItemChipRow(context.itemIcons, m_outputSection);
m_outputSection->getContentLayout()->addWidget(m_outputChips);
// In the direction the materials flow: what goes in, what is being made of it, what
// has come out (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS).
getRuntimeLayout()->addWidget(m_inputSection);
getRuntimeLayout()->addWidget(m_production);
getRuntimeLayout()->addWidget(m_outputSection);
}
void BufferedBuildingContent::refreshConfiguration()
{
const BuildingTarget target = resolveBuildingTarget(getContext(), m_id);
if (!target.isValid())
{
// Gone under the card. SelectionPanel rebuilds on the same refresh; this only
// has to avoid reading it.
return;
}
setBuildingIdentity(target.type, getBuildingTypeName(target.type));
const CycleInfo cycle = getCycleInfo(target);
m_recipeSummary->setSummary(toAmounts(cycle.perCycleInputs),
toAmounts(cycle.perCycleOutputs),
cycle.durationSeconds);
refreshControls(target);
}
void BufferedBuildingContent::refreshRuntime()
{
const BuildingTarget target = resolveBuildingTarget(getContext(), m_id);
if (!target.building)
{
return;
}
setProductionStatusSlot(*target.building);
const CycleInfo cycle = getCycleInfo(target);
const std::vector<ItemChipRow::Entry> inputs =
buildInputEntries(*target.building, cycle);
const std::vector<ItemChipRow::Entry> outputs =
buildOutputEntries(*target.building, cycle);
m_inputChips->setEntries(inputs);
m_outputChips->setEntries(outputs);
m_inputSection->setVisible(!inputs.empty());
m_outputSection->setVisible(!outputs.empty());
m_production->setProduction(cycle.runsProduction, *target.building,
cycle.durationSeconds,
getContext().sim->getCurrentTick());
}
std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildInputEntries(
const Building& building, const CycleInfo& cycle) const
{
std::map<std::string, int> buffered;
for (const std::pair<const ItemType, int>& entry : building.inputBuffer.counts)
{
buffered[entry.first.id] = entry.second;
}
std::vector<ItemChipRow::Entry> entries;
for (const std::string& itemId :
collectItemIds(buffered, cycle.perCycleInputs, cycle.handledInputs))
{
// An auto-recipe building's buffers are sized over every recipe of its type,
// including recipes still locked, so those entries are left out here
// (REQ-UI-SINGLE-SELECTION, REQ-LOCK-UI-RECIPE).
if (!getContext().sim->isItemUnlocked(itemId)) { continue; }
ItemChipRow::Entry chip;
chip.itemId = itemId;
chip.countText = QString::number(lookUp(buffered, itemId));
const int perCycle = lookUp(cycle.perCycleInputs, itemId);
if (perCycle > 0)
{
chip.subLine = tr("/ %1 per cycle").arg(perCycle);
}
else
{
chip.subLine = QString::fromStdString(toDisplayName(itemId));
}
entries.push_back(chip);
}
return entries;
}
std::vector<ItemChipRow::Entry> BufferedBuildingContent::buildOutputEntries(
const Building& building, const CycleInfo& cycle) const
{
// The buffered items plus those still emerging onto the output belts: an emerging
// item still belongs to the output buffer (REQ-MAT-OUTPUT-EMERGE), so leaving it out
// would make it vanish from the panel while it animates.
std::map<std::string, int> buffered;
for (const Item& item : building.outputBuffer.items)
{
buffered[item.type.id]++;
}
for (const std::vector<BeltItemSlot>& lane : building.emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
buffered[slot.item.type.id]++;
}
}
std::vector<ItemChipRow::Entry> entries;
for (const std::string& itemId :
collectItemIds(buffered, cycle.perCycleOutputs, cycle.handledOutputs))
{
if (!getContext().sim->isItemUnlocked(itemId)) { continue; }
ItemChipRow::Entry chip;
chip.itemId = itemId;
// Counted against the buffer's capacity, which is what production stops at
// (REQ-MAT-OUTPUT-BUFFER).
chip.countText = building.outputBuffer.capacity > 0
? tr("%1 / %2").arg(lookUp(buffered, itemId))
.arg(building.outputBuffer.capacity)
: QString::number(lookUp(buffered, itemId));
chip.subLine = QString::fromStdString(toDisplayName(itemId));
entries.push_back(chip);
}
return entries;
}

View File

@@ -1,86 +0,0 @@
#pragma once
#include <map>
#include <string>
#include <vector>
#include "BuildingId.h"
#include "ItemChipRow.h"
#include "SelectionContent.h"
struct Building;
struct BuildingTarget;
class ProductionSection;
class RecipeSummaryRow;
class SectionBox;
// Shared body of the four cards that show one building with buffers -- the Miner and
// Assembler, the Smelter and Reprocessing Plant, the Shipyard, and the Salvage Bay
// (REQ-UI-SELECTION-CONTENT). All four show the same header status, recipe summary,
// buffer contents and production progress; they differ only in what one production cycle
// costs and how long it takes, which is what the subclass supplies.
//
// This is implementation sharing, not a catalog entry: every concrete subclass is one
// row of the content catalog.
class BufferedBuildingContent : public SelectionContent
{
Q_OBJECT
protected:
// What one production cycle of this building consumes, produces, and takes.
struct CycleInfo
{
std::map<std::string, int> perCycleInputs;
std::map<std::string, int> perCycleOutputs;
// Items the card lists whether or not they are currently in the buffers, for a
// building whose recipe is implicit and so has nothing to name while it sits
// between cycles (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING). They carry no
// per-cycle denominator, since no one recipe is in force.
std::vector<std::string> handledInputs;
std::vector<std::string> handledOutputs;
// False when the building produces nothing at all (the Salvage Bay,
// REQ-BLD-SALVAGE-BAY) or has no recipe or schematic selected yet: the
// production section is then not shown (REQ-UI-PRODUCTION-PROGRESS).
bool runsProduction = false;
// 0 only when no recipe describes the cycle at all -- an auto-recipe building
// that has yet to run one (REQ-UI-RECIPE-SUMMARY). The progress line reads
// "idle" whenever no cycle is actually running, whatever this says.
double durationSeconds = 0.0;
};
BufferedBuildingContent(const SelectionContext& context, BuildingId id,
QWidget* parent);
// Called with a construction site's stored configuration too, so the summary of what
// the building will produce is shown before it is built (REQ-BLD-SITE-CONFIG).
virtual CycleInfo getCycleInfo(const BuildingTarget& target) const = 0;
// The subclass's own configuration controls. The identity, the recipe summary, the
// buffers and the production progress are handled here.
virtual void refreshControls(const BuildingTarget& /*target*/) {}
BuildingId getBuildingId() const { return m_id; }
private:
void refreshConfiguration() override;
void refreshRuntime() override;
std::vector<ItemChipRow::Entry> buildInputEntries(const Building& building,
const CycleInfo& cycle) const;
std::vector<ItemChipRow::Entry> buildOutputEntries(const Building& building,
const CycleInfo& cycle) const;
BuildingId m_id;
RecipeSummaryRow* m_recipeSummary;
// Input buffers, production progress, output buffer -- in that order, so the card
// reads the way the materials flow (REQ-UI-SINGLE-SELECTION).
SectionBox* m_inputSection;
ItemChipRow* m_inputChips;
ProductionSection* m_production;
SectionBox* m_outputSection;
ItemChipRow* m_outputChips;
};

View File

@@ -1,34 +0,0 @@
#include "BuildingTarget.h"
#include "FactoryQueries.h"
#include "SelectionContext.h"
#include "Simulation.h"
BuildingTarget resolveBuildingTarget(const SelectionContext& context, BuildingId id)
{
BuildingTarget target;
target.building = findBuilding(context.sim->getFactoryState(), id);
target.site = target.building
? nullptr
: findSite(context.sim->getFactoryState(), id);
if (!target.isValid())
{
return target;
}
target.type = target.building ? target.building->type : target.site->type;
target.recipeId = target.building ? target.building->recipeId : target.site->recipeId;
target.shipLayout = target.building ? target.building->shipLayout : target.site->shipLayout;
target.anchor = target.building ? target.building->anchor : target.site->anchor;
return target;
}
std::optional<BuildingId> asConstructionSite(const SelectionContext& context,
BuildingId id)
{
if (findSite(context.sim->getFactoryState(), id) != nullptr)
{
return id;
}
return std::nullopt;
}

View File

@@ -1,45 +0,0 @@
#pragma once
#include <optional>
#include <string>
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "ShipLayout.h"
struct Building;
struct ConstructionSite;
struct SelectionContext;
// One selected building id resolved to whichever of the two things it names: an
// operational building or a construction site still queued or under construction. A
// site carries the same configuration as the building it will become
// (REQ-BLD-SITE-CONFIG), so the fields both have are read out here once instead of in
// every content that shows a single building.
struct BuildingTarget
{
const Building* building = nullptr; // null while it is still a site
const ConstructionSite* site = nullptr; // null once it is built
BuildingType type = BuildingType::Miner;
std::string recipeId;
std::optional<ShipLayoutConfig> shipLayout;
QPoint anchor;
// False when the id names neither -- the object went away under the panel (it was
// deconstructed, or its site finished and its id was reused).
bool isValid() const { return building != nullptr || site != nullptr; }
};
// Resolves the id against the current factory state. The returned pointers are only
// valid until the simulation next mutates, so this is called per refresh rather than
// cached.
BuildingTarget resolveBuildingTarget(const SelectionContext& context, BuildingId id);
// The id wrapped as a construction site id when it names one, nullopt when it names an
// operational building. Every content showing a single building hands this to
// SelectionContent so the base can apply the site rule (REQ-UI-SELECTION-CARD).
std::optional<BuildingId> asConstructionSite(const SelectionContext& context,
BuildingId id);

View File

@@ -1,76 +0,0 @@
SET(HDRS
${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContext.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContent.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContentFactory.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectionNames.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTarget.h
${CMAKE_CURRENT_SOURCE_DIR}/DebrisScrap.h
${CMAKE_CURRENT_SOURCE_DIR}/StatRow.h
${CMAKE_CURRENT_SOURCE_DIR}/BarRow.h
${CMAKE_CURRENT_SOURCE_DIR}/SectionBox.h
${CMAKE_CURRENT_SOURCE_DIR}/CountRow.h
${CMAKE_CURRENT_SOURCE_DIR}/StatusPill.h
${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.h
${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.h
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.h
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.h
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.h
${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.h
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.h
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltContent.h
${CMAKE_CURRENT_SOURCE_DIR}/SplitterContent.h
${CMAKE_CURRENT_SOURCE_DIR}/MultiBuildingContent.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipContent.h
${CMAKE_CURRENT_SOURCE_DIR}/StationContent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebrisContent.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldMultiContent.h
PARENT_SCOPE
)
SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionContentFactory.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SelectionNames.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTarget.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DebrisScrap.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StatRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BarRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SectionBox.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CountRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StatusPill.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BufferedBuildingContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/RecipeProductionContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/AutoProductionContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipyardContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StorageContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/HqContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/SplitterContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/MultiBuildingContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StationContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DebrisContent.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldMultiContent.cpp
PARENT_SCOPE
)
set(UI_INCLUDE_PATH
${UI_INCLUDE_PATH}
${CMAKE_CURRENT_SOURCE_DIR}
PARENT_SCOPE
)

View File

@@ -1,54 +0,0 @@
#include "ClearBeltControl.h"
#include <QPoint>
#include <QPushButton>
#include <QVBoxLayout>
#include "Building.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "EventManager.h"
#include "FactoryQueries.h"
#include "Simulation.h"
ClearBeltControl::ClearBeltControl(const SelectionContext& context,
const std::vector<BuildingId>& ids, QWidget* parent)
: QWidget(parent)
, m_context(context)
, m_ids(ids)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
QPushButton* button = new QPushButton(tr("Clear stuck items"), this);
layout->addWidget(button);
connect(button, &QPushButton::clicked, this, [this]() { clearSelectedTiles(); });
}
void ClearBeltControl::clearSelectedTiles() const
{
std::vector<QPoint> tiles;
for (BuildingId id : m_ids)
{
const Building* building = findBuilding(m_context.sim->getFactoryState(), id);
if (building && isBeltSubsystemType(building->type))
{
for (const QPoint& cell : building->bodyCells)
{
tiles.push_back(cell);
}
}
}
if (tiles.empty())
{
return;
}
std::shared_ptr<ClearBeltTilesCommand> command =
std::make_shared<ClearBeltTilesCommand>();
command->tiles = std::move(tiles);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}

View File

@@ -1,31 +0,0 @@
#pragma once
#include <vector>
#include <QWidget>
#include "BuildingId.h"
#include "SelectionContext.h"
// The "Clear stuck items" action of the card's runtime group (REQ-UI-BELT-CLEAR): it
// removes every item from the selected belt, splitter and tunnel tiles, which is how a
// stalled line is resolved.
//
// It acts on the whole selection rather than on one tile, which is why a selection of
// belts and tunnel ends aggregates into a single card instead of a count summary
// (REQ-UI-SELECTION-AGGREGATE), and why the count summary shows the action too when a
// belt is among the selected buildings.
class ClearBeltControl : public QWidget
{
Q_OBJECT
public:
ClearBeltControl(const SelectionContext& context,
const std::vector<BuildingId>& ids, QWidget* parent = nullptr);
private:
void clearSelectedTiles() const;
SelectionContext m_context;
std::vector<BuildingId> m_ids;
};

View File

@@ -1,29 +0,0 @@
#include "CountRow.h"
#include <QHBoxLayout>
#include <QLabel>
CountRow::CountRow(const QPixmap& symbol, const QString& name, int count,
QWidget* parent)
: QWidget(parent)
{
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(6);
m_symbolLabel = new QLabel(this);
m_symbolLabel->setPixmap(symbol);
m_symbolLabel->setVisible(!symbol.isNull());
m_nameLabel = new QLabel(name, this);
// The same "x<count>" notation the recipe tooltip and the header's aggregate count
// use (REQ-UI-MULTI-SELECTION).
m_countLabel = new QLabel(tr("x%1").arg(count), this);
m_countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
layout->addWidget(m_symbolLabel);
layout->addWidget(m_nameLabel);
layout->addStretch(1);
layout->addWidget(m_countLabel);
}

View File

@@ -1,25 +0,0 @@
#pragma once
#include <QPixmap>
#include <QString>
#include <QWidget>
class QLabel;
// One "<symbol> <name> x<count>" line of a count summary. The same part serves the
// building summary and the field summary, which count different things the same way
// (REQ-UI-MULTI-SELECTION, REQ-UI-FIELD-MULTI-SELECTION).
class CountRow : public QWidget
{
Q_OBJECT
public:
// An empty symbol leaves the icon off, for the kinds of object that have none.
CountRow(const QPixmap& symbol, const QString& name, int count,
QWidget* parent = nullptr);
private:
QLabel* m_symbolLabel;
QLabel* m_nameLabel;
QLabel* m_countLabel;
};

View File

@@ -1,32 +0,0 @@
#include "DebrisContent.h"
#include <QVBoxLayout>
#include "DebrisScrap.h"
#include "Simulation.h"
#include "StatRow.h"
DebrisContent::DebrisContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, std::nullopt, parent)
, m_debris(request.debris)
{
m_scrapRow = new StatRow(tr("Scrap remaining"), this);
m_scrapRow->setValueEmphasized(true);
getRuntimeLayout()->addWidget(m_scrapRow);
setIdentity(QPixmap(), tr("Debris"));
if (m_debris.size() > 1)
{
setCountSlot(static_cast<int>(m_debris.size()));
}
}
void DebrisContent::refreshRuntime()
{
// The value falls as the debris is collected and as pieces despawn
// (REQ-UI-DEBRIS-CLICK-SELECT), so it is re-summed rather than remembered. With
// several pieces selected it is their sum (REQ-UI-SELECTION-AGGREGATE).
m_scrapRow->setValue(QString::number(
sumDebrisScrap(getContext().sim->getAdmin(), m_debris)));
}

View File

@@ -1,31 +0,0 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class StatRow;
// The card for selected debris (REQ-UI-DEBRIS-PANEL): the scrap still left in it.
//
// It is the field category's aggregating content (REQ-UI-SELECTION-AGGREGATE): several
// pieces of debris show this same card with the count in the header and their scrap
// summed, because that is the one value the card holds and it adds up.
class DebrisContent : public SelectionContent
{
Q_OBJECT
public:
DebrisContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
std::vector<entt::entity> m_debris;
StatRow* m_scrapRow;
};

View File

@@ -1,18 +0,0 @@
#include "DebrisScrap.h"
#include <algorithm>
#include "DebrisSystem.h"
int sumDebrisScrap(const EntityAdmin& admin, const std::vector<entt::entity>& debris)
{
int total = 0;
for (const DebrisInfo& info : getAllDebrisInfo(admin))
{
if (std::find(debris.begin(), debris.end(), info.entity) != debris.end())
{
total += info.amount;
}
}
return total;
}

View File

@@ -1,13 +0,0 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
class EntityAdmin;
// Remaining scrap summed over the given debris, skipping pieces that have already been
// collected or despawned (REQ-RES-DEBRIS-DROP). Shared by the debris card and the field
// count summary, which show the same total in two shapes (REQ-UI-DEBRIS-PANEL,
// REQ-UI-FIELD-MULTI-SELECTION).
int sumDebrisScrap(const EntityAdmin& admin, const std::vector<entt::entity>& debris);

View File

@@ -1,19 +0,0 @@
#include "EmptyNote.h"
#include <QFont>
#include <QPalette>
EmptyNote::EmptyNote(const QString& text, QWidget* parent)
: QLabel(text, parent)
{
setWordWrap(true);
QFont noteFont = font();
noteFont.setItalic(true);
setFont(noteFont);
QPalette notePalette = palette();
notePalette.setColor(QPalette::WindowText,
palette().color(QPalette::Disabled, QPalette::WindowText));
setPalette(notePalette);
}

View File

@@ -1,15 +0,0 @@
#pragma once
#include <QString>
#include <QLabel>
// A dimmed aside explaining why a part of the card is not there yet -- a construction
// site's "no buffers until built" (REQ-UI-SELECTION-CARD). Styled apart from the card's
// values so it reads as an explanation rather than as data.
class EmptyNote : public QLabel
{
Q_OBJECT
public:
explicit EmptyNote(const QString& text, QWidget* parent = nullptr);
};

View File

@@ -1,105 +0,0 @@
#include "FieldMultiContent.h"
#include <map>
#include <string>
#include <QVBoxLayout>
#include "CountRow.h"
#include "DebrisScrap.h"
#include "DisplayName.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "ShipIdentityComponent.h"
#include "Simulation.h"
#include "StatRow.h"
#include "StationBodyComponent.h"
FieldMultiContent::FieldMultiContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, std::nullopt, parent)
, m_debris(request.debris)
, m_scrapRow(nullptr)
{
setIdentity(QPixmap(), tr("Mixed selection"));
setCountSlot(static_cast<int>(request.actors.size() + request.debris.size()));
buildSummary(request.actors);
}
void FieldMultiContent::buildSummary(const std::vector<entt::entity>& actors)
{
EntityAdmin& admin = getContext().sim->getAdmin();
// Grouped by faction, kind and ship schematic, in the order the groups are first
// seen (REQ-UI-FIELD-MULTI-SELECTION).
std::vector<QString> keys;
std::map<QString, int> counts;
std::map<QString, QString> labels;
for (entt::entity actor : actors)
{
if (!admin.isValid(actor)) { continue; }
const bool isEnemy = admin.hasAll<FactionComponent>(actor)
&& admin.get<FactionComponent>(actor).isEnemy;
QString key;
QString label;
if (admin.hasAll<ShipIdentityComponent>(actor))
{
const std::string& id = admin.get<ShipIdentityComponent>(actor).schematicId;
const QString name = QString::fromStdString(toDisplayName(id));
key = (isEnemy ? QStringLiteral("ship:enemy:")
: QStringLiteral("ship:player:"))
+ QString::fromStdString(id);
label = isEnemy ? tr("Enemy %1").arg(name) : name;
}
else if (admin.hasAll<StationBodyComponent>(actor))
{
key = isEnemy ? QStringLiteral("station:enemy")
: QStringLiteral("station:player");
label = isEnemy ? tr("Enemy Defence Station") : tr("Player Defence Station");
}
else
{
continue;
}
if (counts.find(key) == counts.end())
{
keys.push_back(key);
labels[key] = label;
}
counts[key] += 1;
}
for (const QString& key : keys)
{
getRuntimeLayout()->addWidget(
new CountRow(QPixmap(), labels[key], counts[key], this));
}
if (!m_debris.empty())
{
getRuntimeLayout()->addWidget(new CountRow(
QPixmap(), tr("Debris"), static_cast<int>(m_debris.size()), this));
// Indented under the debris row, so the total reads as belonging to it
// (REQ-UI-DEBRIS-PANEL).
m_scrapRow = new StatRow(tr("holding"), this);
m_scrapRow->setIndented(true);
m_scrapRow->setValueEmphasized(true);
getRuntimeLayout()->addWidget(m_scrapRow);
}
}
void FieldMultiContent::refreshRuntime()
{
// The counts are fixed for a given selection -- an actor leaving it re-publishes the
// selection and rebuilds this card -- but the scrap falls as the debris is collected.
if (m_scrapRow)
{
m_scrapRow->setValue(tr("%1 scrap")
.arg(sumDebrisScrap(getContext().sim->getAdmin(), m_debris)));
}
}

View File

@@ -1,33 +0,0 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class StatRow;
// The count summary for a field selection holding more than one object that does not
// aggregate (REQ-UI-FIELD-MULTI-SELECTION): several actors, or actors together with
// debris. A count per type, and the debris' summed scrap when debris is part of it.
class FieldMultiContent : public SelectionContent
{
Q_OBJECT
public:
FieldMultiContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
void buildSummary(const std::vector<entt::entity>& actors);
std::vector<entt::entity> m_debris;
// Null unless debris is part of the selection; the only value here that changes
// while the selection stands.
StatRow* m_scrapRow;
};

View File

@@ -1,61 +0,0 @@
#include "HqContent.h"
#include <vector>
#include <QVBoxLayout>
#include "BarRow.h"
#include "EntityAdmin.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "IconCaption.h"
#include "ItemChipRow.h"
#include "SectionBox.h"
#include "SelectionNames.h"
#include "Simulation.h"
HqContent::HqContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent)
// The HQ is placed before the game starts and can never be deconstructed
// (REQ-BLD-DECONSTRUCT), so it is never a construction site.
: SelectionContent(context, std::nullopt, parent)
{
m_hpBar = new BarRow(tr("HP"), this);
m_stockSection = new SectionBox(tr("Building blocks"), this);
m_stockChips = new ItemChipRow(context.itemIcons, m_stockSection);
m_stockSection->getContentLayout()->addWidget(m_stockChips);
// HP first, as on every card that has it (REQ-UI-SELECTION-CARD, REQ-UI-HQ-PANEL).
getRuntimeLayout()->addWidget(m_hpBar);
getRuntimeLayout()->addWidget(m_stockSection);
setBuildingIdentity(BuildingType::Hq, getBuildingTypeName(BuildingType::Hq));
}
void HqContent::refreshRuntime()
{
// Not a buffer: blocks delivered by belt go straight into the global stock
// (REQ-HQ-BELT-INPUT). Showing it here is what tells the player to route them here
// (REQ-UI-HQ-PANEL).
ItemChipRow::Entry stock;
stock.itemId = kBlockItemId;
stock.countText = QString::number(getContext().sim->getBuildingBlocksStock());
stock.subLine = tr("in stock");
m_stockChips->setEntries(std::vector<ItemChipRow::Entry>{ stock });
// The HQ's health lives on its proxy entity, not on the building
// (REQ-HQ-STATS, REQ-UI-HP-BARS).
EntityAdmin& admin = getContext().sim->getAdmin();
admin.forEach<HqProxyComponent, HealthComponent>(
[this](entt::entity /*entity*/, const HqProxyComponent& /*proxy*/,
const HealthComponent& health)
{
const double fraction = (health.maxHp > 0.0f)
? static_cast<double>(health.hp) / health.maxHp
: 0.0;
m_hpBar->setValue(fraction, tr("%1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f)));
});
}

View File

@@ -1,32 +0,0 @@
#pragma once
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class BarRow;
class ItemChipRow;
class SectionBox;
// The card for the HQ (REQ-UI-HQ-PANEL): the global building blocks stock and the HQ's
// HP. It has no configuration group and no status indicator.
//
// The stock is not a buffer: blocks delivered by belt go straight into the global stock
// (REQ-HQ-BELT-INPUT), which is exactly why the card shows it -- it is what tells the
// player to route blocks here. The HP comes from the HQ's proxy entity rather than from
// the building, since that is where its health lives.
class HqContent : public SelectionContent
{
Q_OBJECT
public:
HqContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
SectionBox* m_stockSection;
ItemChipRow* m_stockChips;
BarRow* m_hpBar;
};

View File

@@ -1,72 +0,0 @@
#include "ItemChip.h"
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QPalette>
#include <QVBoxLayout>
namespace
{
// Point-size rise of the count and drop of the sub-line, relative to the card's text.
const int kCountSizeRisePt = 2;
const int kSubLineSizeDropPt = 1;
} // namespace
ItemChip::ItemChip(const QPixmap& icon, QWidget* parent)
: QWidget(parent)
{
// Its own boxed chrome, drawn with palette colors like the rest of the panel's
// furniture rather than from visuals.toml, which is for world rendering.
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral(
"ItemChip { border: 1px solid palette(mid); border-radius: 3px; }"));
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(4, 3, 4, 3);
layout->setSpacing(6);
m_iconLabel = new QLabel(this);
m_iconLabel->setPixmap(icon);
m_iconLabel->setVisible(!icon.isNull());
QWidget* text = new QWidget(this);
QVBoxLayout* textLayout = new QVBoxLayout(text);
textLayout->setContentsMargins(0, 0, 0, 0);
textLayout->setSpacing(0);
m_countLabel = new QLabel(text);
QFont countFont = m_countLabel->font();
countFont.setPointSize(countFont.pointSize() + kCountSizeRisePt);
m_countLabel->setFont(countFont);
m_subLineLabel = new QLabel(text);
QFont subLineFont = m_subLineLabel->font();
subLineFont.setPointSize(qMax(1, subLineFont.pointSize() - kSubLineSizeDropPt));
m_subLineLabel->setFont(subLineFont);
QPalette subLinePalette = m_subLineLabel->palette();
subLinePalette.setColor(QPalette::WindowText,
palette().color(QPalette::Disabled, QPalette::WindowText));
m_subLineLabel->setPalette(subLinePalette);
m_subLineLabel->hide();
textLayout->addWidget(m_countLabel);
textLayout->addWidget(m_subLineLabel);
layout->addWidget(m_iconLabel);
layout->addWidget(text);
}
void ItemChip::setCount(const QString& count)
{
m_countLabel->setText(count);
}
void ItemChip::setSubLine(const QString& subLine)
{
m_subLineLabel->setText(subLine);
m_subLineLabel->setVisible(!subLine.isEmpty());
}

View File

@@ -1,28 +0,0 @@
#pragma once
#include <QPixmap>
#include <QString>
#include <QWidget>
class QLabel;
// One buffered item: its icon, its count in a larger type, and a sub-line beneath the
// count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities
// rather than as a run of text.
class ItemChip : public QWidget
{
Q_OBJECT
public:
// An empty icon leaves the icon off and the chip laid out around the text alone.
ItemChip(const QPixmap& icon, QWidget* parent = nullptr);
void setCount(const QString& count);
// Left empty for an item with nothing to say beneath its count.
void setSubLine(const QString& subLine);
private:
QLabel* m_iconLabel;
QLabel* m_countLabel;
QLabel* m_subLineLabel;
};

View File

@@ -1,83 +0,0 @@
#include "ItemChipRow.h"
#include <QGridLayout>
#include "ItemChip.h"
#include "ItemIconCache.h"
namespace
{
// Size the item icon is drawn at inside a chip, in device-independent pixels.
const int kChipIconSizePx = 18;
// Chips per row. Two fit the panel's capped width side by side; a third would force the
// counts to shrink.
const int kChipsPerRow = 2;
} // namespace
ItemChipRow::ItemChipRow(ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent)
, m_itemIcons(itemIcons)
{
m_layout = new QGridLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(4);
}
void ItemChipRow::setEntries(const std::vector<Entry>& entries)
{
std::vector<std::string> itemIds;
itemIds.reserve(entries.size());
for (const Entry& entry : entries)
{
itemIds.push_back(entry.itemId);
}
// Only the set of items changing is a structural change; the counts change every
// tick and must not cost a widget rebuild.
if (itemIds != m_itemIds)
{
m_itemIds = std::move(itemIds);
rebuildChips(entries);
}
for (std::size_t index = 0; index < entries.size(); ++index)
{
m_chips[index]->setCount(entries[index].countText);
m_chips[index]->setSubLine(entries[index].subLine);
}
setVisible(!entries.empty());
}
void ItemChipRow::rebuildChips(const std::vector<Entry>& entries)
{
for (ItemChip* chip : m_chips)
{
m_layout->removeWidget(chip);
chip->deleteLater();
}
m_chips.clear();
for (std::size_t index = 0; index < entries.size(); ++index)
{
const std::string& itemId = entries[index].itemId;
// A missing icon file is not an error (REQ-UI-ITEM-ICON): the chip is then laid
// out around its count alone.
const QPixmap icon = m_itemIcons->hasIcon(itemId)
? m_itemIcons->getPixmap(itemId, kChipIconSizePx)
: QPixmap();
ItemChip* chip = new ItemChip(icon, this);
m_layout->addWidget(chip, static_cast<int>(index) / kChipsPerRow,
static_cast<int>(index) % kChipsPerRow);
// Shown right away, because the panel measures itself as soon as this returns. A
// widget created under an already-visible parent starts hidden, and a layout
// treats a hidden item as empty, so an unshown chip would add nothing to the
// size hint and the panel would be fitted to a buffer section that looks empty.
chip->show();
m_chips.push_back(chip);
}
}

View File

@@ -1,44 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include <QString>
#include <QWidget>
class ItemChip;
class ItemIconCache;
class QGridLayout;
// The buffered items of one building, each as a chip carrying the item's icon, its
// current count and a sub-line (REQ-UI-SINGLE-SELECTION): the per-cycle amount for an
// input, the item's name for an output.
//
// The chips are rebuilt only when the set of items changes, not when their counts do, so
// a refresh at tick rate updates numbers instead of churning widgets.
class ItemChipRow : public QWidget
{
Q_OBJECT
public:
struct Entry
{
std::string itemId;
QString countText;
QString subLine;
};
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with
// no icon file simply shows no icon, which is not an error. Not owned.
explicit ItemChipRow(ItemIconCache* itemIcons, QWidget* parent = nullptr);
void setEntries(const std::vector<Entry>& entries);
private:
void rebuildChips(const std::vector<Entry>& entries);
ItemIconCache* m_itemIcons;
QGridLayout* m_layout;
std::vector<std::string> m_itemIds; // what the chips currently stand for
std::vector<ItemChip*> m_chips;
};

View File

@@ -1,99 +0,0 @@
#include "MultiBuildingContent.h"
#include <map>
#include <QVBoxLayout>
#include "Building.h"
#include "BuildingIconCache.h"
#include "ClearBeltControl.h"
#include "CountRow.h"
#include "FactoryQueries.h"
#include "GameConfig.h"
#include "SelectionNames.h"
#include "Simulation.h"
#include "StatRow.h"
namespace
{
// Size the type symbol is drawn at on a count row, matching the card header's chip.
const int kCountSymbolSizePx = 20;
} // namespace
MultiBuildingContent::MultiBuildingContent(const SelectionContext& context,
const SelectionRequest& request,
QWidget* parent)
: SelectionContent(context, std::nullopt, parent)
, m_ids(request.buildings)
{
// The header names the size of the selection instead of an object
// (REQ-UI-MULTI-SELECTION).
setIdentity(QPixmap(), tr("%1 buildings").arg(static_cast<int>(m_ids.size())));
buildSummary();
// A selection holding any belt-subsystem tile can still be cleared as a whole
// (REQ-UI-BELT-CLEAR), even though the mixture is what kept it from aggregating.
bool hasBeltTile = false;
for (BuildingId id : m_ids)
{
const Building* building = findBuilding(context.sim->getFactoryState(), id);
if (building && isBeltSubsystemType(building->type))
{
hasBeltTile = true;
break;
}
}
if (hasBeltTile)
{
getRuntimeLayout()->addWidget(new ClearBeltControl(context, m_ids, this));
}
}
void MultiBuildingContent::buildSummary()
{
std::map<BuildingType, int> counts;
for (BuildingId id : m_ids)
{
const Building* building =
findBuilding(getContext().sim->getFactoryState(), id);
if (building)
{
counts[building->type]++;
continue;
}
const ConstructionSite* site =
findSite(getContext().sim->getFactoryState(), id);
if (site)
{
counts[site->type]++;
}
}
int totalCost = 0;
for (const std::pair<const BuildingType, int>& entry : counts)
{
getRuntimeLayout()->addWidget(new CountRow(
getContext().buildingIcons->getChip(buildingTypeId(entry.first),
kCountSymbolSizePx),
getBuildingTypeName(entry.first), entry.second, this));
// Only player-placeable buildings count toward the total; the HQ and defence
// stations are excluded (REQ-UI-MULTI-SELECTION). A construction site counts at
// its type's full placement cost regardless of progress.
const BuildingDef* def =
getContext().config->buildings.findBuildingDef(entry.first);
if (def && def->playerPlaceable)
{
totalCost += def->cost * entry.second;
}
}
StatRow* totalRow = new StatRow(tr("Total cost"), this);
totalRow->setValue(QString::number(totalCost));
totalRow->setValueEmphasized(true);
getRuntimeLayout()->addWidget(totalRow);
}

View File

@@ -1,31 +0,0 @@
#pragma once
#include <vector>
#include "BuildingId.h"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
// The count summary for several selected buildings that do not aggregate
// (REQ-UI-MULTI-SELECTION, REQ-UI-SELECTION-AGGREGATE): how many of each type, and the
// total building block cost of the selection. No per-building detail is shown.
class MultiBuildingContent : public SelectionContent
{
Q_OBJECT
public:
MultiBuildingContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent = nullptr);
protected:
// The summary is fixed for a given selection -- how many of each type were selected,
// and what they cost -- so it is built once and has nothing to keep current. A
// building leaving the selection re-publishes it and rebuilds this card.
void refreshRuntime() override {}
private:
void buildSummary();
std::vector<BuildingId> m_ids;
};

View File

@@ -1,48 +0,0 @@
#include "ProductionSection.h"
#include <algorithm>
#include <QVBoxLayout>
#include "BarRow.h"
#include "Building.h"
#include "SectionBox.h"
ProductionSection::ProductionSection(QWidget* parent)
: QWidget(parent)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
m_section = new SectionBox(tr("Production"), this);
m_bar = new BarRow(QString(), m_section);
m_section->getContentLayout()->addWidget(m_bar);
layout->addWidget(m_section);
}
void ProductionSection::setProduction(bool runsProduction, const Building& building,
double durationSeconds, Tick currentTick)
{
// Nothing selected to produce means no progress indicator at all
// (REQ-UI-PRODUCTION-PROGRESS).
if (!runsProduction)
{
hide();
return;
}
show();
// No running cycle, or none whose length is known: the bar is empty and reads idle.
if (durationSeconds <= 0.0 || !building.production.has_value())
{
m_bar->setValue(0.0, tr("idle"));
return;
}
const Tick cycleTicks = secondsToTicks(durationSeconds);
const Tick elapsed = currentTick - (building.production->completesAt - cycleTicks);
const Tick clamped = std::max(Tick(0), std::min(cycleTicks, elapsed));
const int percent = static_cast<int>(clamped * 100 / cycleTicks);
m_bar->setValue(static_cast<double>(clamped) / cycleTicks, tr("%1%").arg(percent));
}

View File

@@ -1,35 +0,0 @@
#pragma once
#include <QWidget>
#include "Tick.h"
struct Building;
class BarRow;
class SectionBox;
// The cycle time and production progress of one building (REQ-UI-PRODUCTION-PROGRESS).
//
// Reading the progress off an active cycle is the same for every building type, so it
// happens here; working out how long that cycle is differs per type (a recipe's
// duration, a schematic's production time plus its modules'), so the owning content
// supplies it.
class ProductionSection : public QWidget
{
Q_OBJECT
public:
explicit ProductionSection(QWidget* parent = nullptr);
// runsProduction false hides the section entirely -- the building produces nothing
// (a Salvage Bay) or has no recipe or schematic selected yet. When it is true but
// durationSeconds is 0 or less, the building is between cycles with no single recipe
// to name a cycle time for (an idle auto-recipe building), so the progress line
// reads "idle" and the cycle time is left out.
void setProduction(bool runsProduction, const Building& building,
double durationSeconds, Tick currentTick);
private:
SectionBox* m_section;
BarRow* m_bar;
};

View File

@@ -1,51 +0,0 @@
#include "RecipeProductionContent.h"
#include <QVBoxLayout>
#include "BuildingTarget.h"
#include "GameConfig.h"
#include "RecipeSelectionControl.h"
RecipeProductionContent::RecipeProductionContent(const SelectionContext& context,
const SelectionRequest& request,
QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
const BuildingTarget target = resolveBuildingTarget(context, getBuildingId());
// Shown for a construction site too: a site is configured exactly like the building
// it will become (REQ-BLD-SITE-CONFIG).
m_recipeControl = new RecipeSelectionControl(context, getBuildingId(), target.type,
this);
getConfigurationLayout()->insertWidget(0, m_recipeControl);
}
void RecipeProductionContent::refreshControls(const BuildingTarget& target)
{
m_recipeControl->setRecipeId(target.recipeId);
}
BufferedBuildingContent::CycleInfo RecipeProductionContent::getCycleInfo(
const BuildingTarget& target) const
{
CycleInfo info;
const RecipeDef* recipe = target.recipeId.empty()
? nullptr
: getContext().config->recipes.findRecipeDef(target.recipeId, target.type);
if (!recipe)
{
return info;
}
for (const RecipeIngredient& ingredient : recipe->inputs)
{
info.perCycleInputs[ingredient.item] = ingredient.amount;
}
for (const RecipeOutput& output : recipe->outputs)
{
info.perCycleOutputs[output.item] = output.amount;
}
info.runsProduction = true;
info.durationSeconds = recipe->durationSeconds;
return info;
}

View File

@@ -1,26 +0,0 @@
#pragma once
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
class RecipeSelectionControl;
// The card for a Miner or an Assembler (REQ-UI-SELECTION-CONTENT): a player-selected
// recipe, plus the buffers and production progress every buffered building shows. The
// two share a card because both run one selected recipe; a Miner simply has no inputs,
// so its input buffer section shows nothing of its own accord.
class RecipeProductionContent : public BufferedBuildingContent
{
Q_OBJECT
public:
RecipeProductionContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent = nullptr);
protected:
void refreshControls(const BuildingTarget& target) override;
CycleInfo getCycleInfo(const BuildingTarget& target) const override;
private:
RecipeSelectionControl* m_recipeControl;
};

View File

@@ -1,57 +0,0 @@
#include "RecipeSelectionControl.h"
#include <vector>
#include <QPushButton>
#include <QVBoxLayout>
#include "EventManager.h"
#include "RecipeSelectionDialog.h"
#include "RecipeSelectionRequestedEvent.h"
#include "Simulation.h"
RecipeSelectionControl::RecipeSelectionControl(const SelectionContext& context,
BuildingId id, BuildingType type,
QWidget* parent)
: QWidget(parent)
, m_context(context)
, m_id(id)
, m_type(type)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
m_button = new QPushButton(this);
layout->addWidget(m_button);
connect(m_button, &QPushButton::clicked, this, [this]() {
// Sent synchronously: MainWindow pauses the game, runs the modal dialog, and
// restores the speed before this returns. The chosen recipe is only enqueued as
// a command though, and drains on a later frame -- so the caption follows from
// the next refresh, not from here.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<RecipeSelectionRequestedEvent>(m_id));
});
}
void RecipeSelectionControl::setRecipeId(const std::string& recipeId)
{
const std::vector<RecipeSelectionOption> options =
buildRecipeSelectionOptions(m_type, *m_context.sim, *m_context.config);
for (const RecipeSelectionOption& option : options)
{
if (option.id == recipeId && !option.id.empty())
{
m_button->setText(option.caption);
m_button->setToolTip(option.tooltip);
return;
}
}
m_button->setText(m_type == BuildingType::Shipyard
? tr("Select schematic")
: tr("Select recipe"));
m_button->setToolTip(QString());
}

View File

@@ -1,38 +0,0 @@
#pragma once
#include <string>
#include <QWidget>
#include "BuildingId.h"
#include "BuildingType.h"
#include "SelectionContext.h"
class QPushButton;
// The recipe/schematic selection control of the card's configuration group: one button
// captioned with the current selection that opens the modal selection dialog
// (REQ-UI-SELECT-BUTTON, REQ-UI-CONFIG-INLINE). Used by the Miner and Assembler for
// their recipe and by the Shipyard for its schematic; the placeholder caption is the
// only difference between the two.
//
// The control does not apply the choice itself: it asks for the dialog and the choice
// arrives back as a command, so the caption follows from the next refresh rather than
// from the click.
class RecipeSelectionControl : public QWidget
{
Q_OBJECT
public:
RecipeSelectionControl(const SelectionContext& context, BuildingId id,
BuildingType type, QWidget* parent = nullptr);
// Re-reads the caption and tooltip for the currently configured recipe id.
void setRecipeId(const std::string& recipeId);
private:
SelectionContext m_context;
BuildingId m_id;
BuildingType m_type;
QPushButton* m_button;
};

View File

@@ -1,120 +0,0 @@
#include "RecipeSummaryRow.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLayoutItem>
#include <QPixmap>
#include "ItemIconCache.h"
namespace
{
// Size the item icons are drawn at on the summary line, in device-independent pixels.
const int kSummaryIconSizePx = 14;
// Adds a freshly built label to the summary and shows it.
//
// The show is what makes it count: a widget created under an already-visible parent
// starts hidden, and a layout treats a hidden item as empty, so an unshown label would
// add nothing to the size hint the panel measures itself against as soon as this
// returns.
void addAndShow(QHBoxLayout* layout, QLabel* label)
{
layout->addWidget(label);
label->show();
}
} // namespace
RecipeSummaryRow::RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent)
: QWidget(parent)
, m_itemIcons(itemIcons)
{
m_layout = new QHBoxLayout(this);
m_layout->setContentsMargins(0, 0, 0, 0);
m_layout->setSpacing(4);
hide();
}
void RecipeSummaryRow::setSummary(const std::vector<Amount>& inputs,
const std::vector<Amount>& outputs,
double durationSeconds)
{
if (outputs.empty() && inputs.empty())
{
// Forgotten as well as hidden, so re-selecting the same recipe later is seen as
// a change and shows the row again.
m_inputs.clear();
m_outputs.clear();
m_durationSeconds = -1.0;
hide();
return;
}
if (inputs == m_inputs && outputs == m_outputs
&& durationSeconds == m_durationSeconds)
{
return;
}
m_inputs = inputs;
m_outputs = outputs;
m_durationSeconds = durationSeconds;
rebuild(inputs, outputs, durationSeconds);
show();
}
void RecipeSummaryRow::rebuild(const std::vector<Amount>& inputs,
const std::vector<Amount>& outputs,
double durationSeconds)
{
while (QLayoutItem* item = m_layout->takeAt(0))
{
if (item->widget())
{
item->widget()->deleteLater();
}
delete item;
}
addAmounts(inputs);
if (!inputs.empty())
{
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
addAndShow(m_layout, new QLabel(QString(rightArrow), this));
}
addAmounts(outputs);
if (durationSeconds > 0.0)
{
const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT
addAndShow(m_layout, new QLabel(
QStringLiteral("%1 %2").arg(middleDot).arg(
tr("%1 s").arg(durationSeconds, 0, 'f', 1)), this));
}
m_layout->addStretch(1);
}
void RecipeSummaryRow::addAmounts(const std::vector<Amount>& amounts)
{
for (const Amount& entry : amounts)
{
// A missing icon file is not an error (REQ-UI-ITEM-ICON): the item's id then
// stands in for its icon.
if (m_itemIcons->hasIcon(entry.itemId))
{
QLabel* iconLabel = new QLabel(this);
iconLabel->setPixmap(
m_itemIcons->getPixmap(entry.itemId, kSummaryIconSizePx));
addAndShow(m_layout, iconLabel);
}
else
{
addAndShow(m_layout,
new QLabel(QString::fromStdString(entry.itemId), this));
}
addAndShow(m_layout, new QLabel(QString::number(entry.amount), this));
}
}

View File

@@ -1,52 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include <QWidget>
class ItemIconCache;
class QHBoxLayout;
// What one production cycle does, on a line: each input item with its per-cycle amount,
// an arrow, each output with its amount, and the cycle time
// (REQ-UI-RECIPE-SUMMARY). It restates the selected recipe without the player having to
// open the selection dialog, and it is the panel's only display of the cycle time.
class RecipeSummaryRow : public QWidget
{
Q_OBJECT
public:
struct Amount
{
std::string itemId;
int amount = 0;
bool operator==(const Amount& other) const
{
return itemId == other.itemId && amount == other.amount;
}
};
// itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with
// no icon file falls back to its id. Not owned.
explicit RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent = nullptr);
// Hides the row when there is nothing selected to produce, which is how a building
// with no recipe shows no summary at all.
void setSummary(const std::vector<Amount>& inputs,
const std::vector<Amount>& outputs, double durationSeconds);
private:
void rebuild(const std::vector<Amount>& inputs, const std::vector<Amount>& outputs,
double durationSeconds);
void addAmounts(const std::vector<Amount>& amounts);
ItemIconCache* m_itemIcons;
QHBoxLayout* m_layout;
// What the row currently shows, so a refresh at tick rate rebuilds it only when the
// recipe actually changed.
std::vector<Amount> m_inputs;
std::vector<Amount> m_outputs;
double m_durationSeconds = -1.0;
};

View File

@@ -1,50 +0,0 @@
#include "SectionBox.h"
#include <QFont>
#include <QLabel>
#include <QPalette>
#include <QVBoxLayout>
namespace
{
// Point-size drop of the caption relative to the card's text, so it reads as a heading
// without competing with the values beneath it.
const int kCaptionSizeDropPt = 1;
} // namespace
SectionBox::SectionBox(const QString& caption, QWidget* parent)
: QWidget(parent)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(2);
m_captionLabel = new QLabel(caption.toUpper(), this);
QFont captionFont = m_captionLabel->font();
captionFont.setPointSize(qMax(1, captionFont.pointSize() - kCaptionSizeDropPt));
captionFont.setBold(true);
m_captionLabel->setFont(captionFont);
// Dimmed to the palette's disabled text, so the caption sits behind its content.
QPalette captionPalette = m_captionLabel->palette();
captionPalette.setColor(QPalette::WindowText,
palette().color(QPalette::Disabled, QPalette::WindowText));
m_captionLabel->setPalette(captionPalette);
m_captionLabel->setVisible(!caption.isEmpty());
QWidget* content = new QWidget(this);
m_contentLayout = new QVBoxLayout(content);
m_contentLayout->setContentsMargins(0, 0, 0, 0);
m_contentLayout->setSpacing(2);
layout->addWidget(m_captionLabel);
layout->addWidget(content);
}
QVBoxLayout* SectionBox::getContentLayout()
{
return m_contentLayout;
}

View File

@@ -1,28 +0,0 @@
#pragma once
#include <QString>
#include <QWidget>
class QLabel;
class QVBoxLayout;
// A captioned group of parts within a card's configuration or runtime group
// (REQ-UI-SELECTION-CARD): a small heading over whatever the owner puts inside it.
//
// A section and its caption are shown or hidden as one, which is what lets a card list
// its sections unconditionally and simply hide the ones with nothing in them -- a Miner
// consumes nothing, so its input buffer section is never shown.
class SectionBox : public QWidget
{
Q_OBJECT
public:
explicit SectionBox(const QString& caption, QWidget* parent = nullptr);
// The layout to add this section's parts to.
QVBoxLayout* getContentLayout();
private:
QLabel* m_captionLabel;
QVBoxLayout* m_contentLayout;
};

View File

@@ -1,258 +0,0 @@
#include "SelectionContent.h"
#include <algorithm>
#include <string>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QVBoxLayout>
#include "BarRow.h"
#include "BuildingIconCache.h"
#include "EmptyNote.h"
#include "FactoryQueries.h"
#include "GameConfig.h"
#include "ProductionRules.h"
#include "SectionBox.h"
#include "Simulation.h"
#include "StatusPill.h"
#include "Tick.h"
#include "VisualsConfig.h"
namespace
{
// Size of the identity chip in the card header, in device-independent pixels. Smaller
// than the build button's 32 px chip: here it labels a line of text rather than being
// the whole button face.
const int kSymbolSizePx = 20;
// Spacing inside the card and between the header's elements.
const int kCardSpacingPx = 6;
const int kHeaderSpacingPx = 6;
// The Salvage Bay has no recipe and no cycle: its two states say whether it is holding
// scrap, not whether it is producing (REQ-BLD-SALVAGE-BAY, REQ-UI-SELECTION-STATUS).
QString getStatusCaption(ProductionStatus status, bool isSalvageBay)
{
switch (status)
{
case ProductionStatus::Unconfigured: return QObject::tr("no recipe");
case ProductionStatus::Producing:
return isSalvageBay ? QObject::tr("holding scrap")
: QObject::tr("producing");
case ProductionStatus::Starved:
return isSalvageBay ? QObject::tr("empty") : QObject::tr("missing input");
case ProductionStatus::Blocked: return QObject::tr("output full");
}
return QString();
}
// Every caption the status slot can end up showing for this building, so the header can
// keep its width as the state changes rather than the panel jumping with it.
QStringList getAllStatusCaptions(bool isSalvageBay)
{
QStringList captions;
for (ProductionStatus status : { ProductionStatus::Unconfigured,
ProductionStatus::Producing,
ProductionStatus::Starved,
ProductionStatus::Blocked })
{
captions << getStatusCaption(status, isSalvageBay);
}
return captions;
}
} // namespace
SelectionContent::SelectionContent(const SelectionContext& context,
std::optional<BuildingId> constructionSiteId,
QWidget* parent)
: QWidget(parent)
, m_context(context)
, m_siteId(constructionSiteId)
, m_constructionSection(nullptr)
, m_constructionBar(nullptr)
{
QVBoxLayout* cardLayout = new QVBoxLayout(this);
cardLayout->setContentsMargins(0, 0, 0, 0);
cardLayout->setSpacing(kCardSpacingPx);
// Header: identity symbol, name, and the right slot pushed to the far edge
// (REQ-UI-SELECTION-CARD).
QWidget* header = new QWidget(this);
QHBoxLayout* headerLayout = new QHBoxLayout(header);
headerLayout->setContentsMargins(0, 0, 0, 0);
headerLayout->setSpacing(kHeaderSpacingPx);
m_symbolLabel = new QLabel(header);
m_symbolLabel->hide();
m_nameLabel = new QLabel(header);
QFont nameFont = m_nameLabel->font();
nameFont.setBold(true);
m_nameLabel->setFont(nameFont);
m_statusPill = new StatusPill(header);
headerLayout->addWidget(m_symbolLabel);
headerLayout->addWidget(m_nameLabel);
headerLayout->addStretch(1);
headerLayout->addWidget(m_statusPill);
cardLayout->addWidget(header);
m_configurationGroup = new QWidget(this);
QVBoxLayout* configurationLayout = new QVBoxLayout(m_configurationGroup);
configurationLayout->setContentsMargins(0, 0, 0, 0);
configurationLayout->setSpacing(kCardSpacingPx);
cardLayout->addWidget(m_configurationGroup);
m_runtimeGroup = new QWidget(this);
QVBoxLayout* runtimeLayout = new QVBoxLayout(m_runtimeGroup);
runtimeLayout->setContentsMargins(0, 0, 0, 0);
runtimeLayout->setSpacing(kCardSpacingPx);
cardLayout->addWidget(m_runtimeGroup);
if (m_siteId.has_value())
{
// A site has no buffers and runs no production cycle, so whatever the subclass
// puts into the runtime group is not shown at all; the construction section
// takes its place (REQ-BLD-SITE-CONFIG, REQ-UI-SELECTION-CARD). The subclass
// still fills the group -- it just never becomes visible.
m_runtimeGroup->hide();
m_constructionSection = new SectionBox(tr("Construction"), this);
m_constructionBar = new BarRow(QString(), m_constructionSection);
m_constructionSection->getContentLayout()->addWidget(m_constructionBar);
m_constructionSection->getContentLayout()->addWidget(
new EmptyNote(tr("No buffers until built"), m_constructionSection));
// Directly below the header rather than where the runtime group sits, so how far
// along the site is reads before what it is configured to become
// (REQ-UI-SELECTION-CARD).
cardLayout->insertWidget(1, m_constructionSection);
setSlot(QColor(), tr("constructing"));
}
}
SelectionContent::~SelectionContent() = default;
void SelectionContent::refresh()
{
refreshConfiguration();
if (m_siteId.has_value())
{
refreshConstruction();
return;
}
refreshRuntime();
}
QVBoxLayout* SelectionContent::getConfigurationLayout()
{
return static_cast<QVBoxLayout*>(m_configurationGroup->layout());
}
QVBoxLayout* SelectionContent::getRuntimeLayout()
{
return static_cast<QVBoxLayout*>(m_runtimeGroup->layout());
}
void SelectionContent::setIdentity(const QPixmap& symbol, const QString& name)
{
m_symbolLabel->setPixmap(symbol);
m_symbolLabel->setVisible(!symbol.isNull());
m_nameLabel->setText(name);
}
void SelectionContent::setBuildingIdentity(BuildingType type, const QString& name)
{
const std::string iconName = buildingTypeId(type);
setIdentity(m_context.buildingIcons->getChip(iconName, kSymbolSizePx), name);
}
void SelectionContent::setSlot(const QColor& dotColor, const QString& caption)
{
m_statusPill->setStatus(dotColor, m_context.visuals->statusLight.outline, caption);
}
void SelectionContent::setCountSlot(int count)
{
setSlot(QColor(), tr("x%1").arg(count));
}
void SelectionContent::clearSlot()
{
setSlot(QColor(), QString());
}
void SelectionContent::setProductionStatusSlot(const Building& building)
{
// The classification is the simulation's, so the panel and the world's status light
// can never disagree (REQ-UI-SELECTION-STATUS, REQ-UI-STATUS-LIGHT).
const std::optional<ProductionStatus> status =
getProductionStatus(*m_context.config, building);
if (!status.has_value())
{
clearSlot();
return;
}
const bool isSalvageBay = (building.type == BuildingType::SalvageBay);
// Room for every caption this building can show, so the panel keeps its width as the
// building's state changes rather than jumping with it.
reserveSlotFor(getAllStatusCaptions(isSalvageBay));
const StatusLightVisuals& colors = m_context.visuals->statusLight;
QColor dotColor;
switch (*status)
{
case ProductionStatus::Unconfigured: dotColor = colors.grey; break;
case ProductionStatus::Producing: dotColor = colors.green; break;
case ProductionStatus::Starved: dotColor = colors.red; break;
case ProductionStatus::Blocked: dotColor = colors.yellow; break;
}
setSlot(dotColor, getStatusCaption(*status, isSalvageBay));
}
void SelectionContent::reserveSlotFor(const QStringList& captions)
{
m_statusPill->reserveFor(captions);
}
void SelectionContent::refreshConstruction()
{
const ConstructionSite* site =
findSite(m_context.sim->getFactoryState(), *m_siteId);
if (!site)
{
// The site finished or was removed under the card. SelectionPanel rebuilds on
// the same refresh, so this only has to avoid reading a dead site.
return;
}
if (site->completesAt == 0)
{
// Placed but not yet at the head of the construction queue (REQ-BLD-QUEUE), so
// there is no progress to show yet.
m_constructionBar->setValue(0.0, tr("Queued"));
return;
}
const BuildingDef* def = m_context.config->buildings.findBuildingDef(site->type);
if (!def || def->constructionTimeSeconds <= 0)
{
m_constructionBar->setValue(0.0, tr("Building..."));
return;
}
const Tick duration = secondsToTicks(def->constructionTimeSeconds);
const Tick elapsed =
m_context.sim->getCurrentTick() - (site->completesAt - duration);
const Tick clamped = std::max(Tick(0), std::min(duration, elapsed));
const int percent = static_cast<int>(clamped * 100 / duration);
m_constructionBar->setValue(static_cast<double>(clamped) / duration,
tr("%1%").arg(percent));
}

View File

@@ -1,110 +0,0 @@
#pragma once
#include <optional>
#include <QColor>
#include <QPixmap>
#include <QString>
#include <QStringList>
#include <QWidget>
#include "BuildingId.h"
#include "BuildingType.h"
#include "SelectionContext.h"
struct Building;
class BarRow;
class QLabel;
class QVBoxLayout;
class SectionBox;
class StatusPill;
// One card of the selection panel: the content shown for a particular kind of selection
// (REQ-UI-SELECTION-CARD). Every content is this base plus the parts its constructor
// puts into the two groups; which content is shown for which selection is decided in
// SelectionContentFactory (REQ-UI-SELECTION-CONTENT).
//
// The card has three parts, top to bottom:
// * the header -- identity symbol, name, and one optional right slot (a status
// indicator, a ship's behavior, or an object count);
// * the configuration group -- controls that change how the object is set up;
// * the runtime group -- what the object is currently doing.
//
// A construction site keeps its configuration group and has its whole runtime group
// replaced by the construction section (REQ-BLD-SITE-CONFIG). That rule lives here and
// nowhere else: a subclass fills both groups unconditionally in its constructor and
// never asks whether it is showing a site.
class SelectionContent : public QWidget
{
Q_OBJECT
public:
~SelectionContent() override;
// Re-reads the live values behind the card. It never changes the card's structure:
// a change that would (a site finishing, the selection changing) is a rebuild, and
// SelectionPanel owns that decision.
void refresh();
protected:
// constructionSiteId is set only while the card shows a construction site, in which
// case this base builds and drives the construction section in place of whatever the
// subclass puts into the runtime group.
SelectionContent(const SelectionContext& context,
std::optional<BuildingId> constructionSiteId,
QWidget* parent);
// Live values of the runtime group. Not called while the card shows a construction
// site, which has neither buffers nor a production cycle (REQ-BLD-SITE-CONFIG).
virtual void refreshRuntime() = 0;
// Live values of the configuration group. Called for a site too, because a site is
// configured exactly like the building it will become (REQ-BLD-SITE-CONFIG).
virtual void refreshConfiguration() {}
const SelectionContext& getContext() const { return m_context; }
// The two group layouts a subclass adds its parts to, in its constructor.
QVBoxLayout* getConfigurationLayout();
QVBoxLayout* getRuntimeLayout();
void setIdentity(const QPixmap& symbol, const QString& name);
// Header symbol for a building type: its chip icon, or nothing when the icon file is
// missing -- which is not an error (REQ-UI-BUILD-ICON, REQ-UI-SELECTION-CARD).
void setBuildingIdentity(BuildingType type, const QString& name);
// Fills the header's right slot. A null dot color leaves the dot off, for the slots
// that are a plain caption (a construction site, a ship's behavior).
void setSlot(const QColor& dotColor, const QString& caption);
// "x<count>", for an aggregated multi-selection (REQ-UI-SELECTION-AGGREGATE).
void setCountSlot(int count);
void clearSlot();
// Reserves header room for the widest caption the slot will ever show. The panel is
// sized to its card (REQ-UI-SELECTION-PANEL), so without this it changes width every
// time the caption does -- and a card whose chips wrap changes height with it.
void reserveSlotFor(const QStringList& captions);
// Fills the right slot from the building's production status, mapped to the same
// colors and states the world's status light uses (REQ-UI-SELECTION-STATUS). Leaves
// the slot empty for a type that has no status light.
void setProductionStatusSlot(const Building& building);
private:
void refreshConstruction();
SelectionContext m_context;
// Set while this card shows a construction site rather than a finished object.
std::optional<BuildingId> m_siteId;
QLabel* m_symbolLabel;
QLabel* m_nameLabel;
StatusPill* m_statusPill;
QWidget* m_configurationGroup;
QWidget* m_runtimeGroup;
// Replaces the runtime group while this card shows a construction site; both null
// otherwise.
SectionBox* m_constructionSection;
BarRow* m_constructionBar;
};

View File

@@ -1,187 +0,0 @@
#include "SelectionContentFactory.h"
#include "AutoProductionContent.h"
#include "BeltContent.h"
#include "DebrisContent.h"
#include "FactoryQueries.h"
#include "FieldMultiContent.h"
#include "HqContent.h"
#include "MultiBuildingContent.h"
#include "RecipeProductionContent.h"
#include "ShipContent.h"
#include "ShipIdentityComponent.h"
#include "ShipyardContent.h"
#include "SplitterContent.h"
#include "StationBodyComponent.h"
#include "StationContent.h"
#include "StorageContent.h"
#include "Simulation.h"
namespace
{
// The catalog row a single building type belongs to (REQ-UI-SELECTION-CONTENT).
SelectionContentKind getKindForType(BuildingType type)
{
switch (type)
{
case BuildingType::Miner:
case BuildingType::Assembler:
return SelectionContentKind::RecipeProduction;
case BuildingType::Smelter:
case BuildingType::ReprocessingPlant:
return SelectionContentKind::AutoProduction;
case BuildingType::Shipyard:
return SelectionContentKind::Shipyard;
case BuildingType::SalvageBay:
return SelectionContentKind::Storage;
case BuildingType::Hq:
return SelectionContentKind::Hq;
case BuildingType::Belt:
case BuildingType::TunnelEntry:
case BuildingType::TunnelExit:
return SelectionContentKind::Belt;
case BuildingType::Splitter:
return SelectionContentKind::Splitter;
case BuildingType::PlayerDefenceStation:
case BuildingType::EnemyDefenceStation:
// Defence stations are field objects backed by entities; these two enum
// values exist only for cost and visuals lookup and never reach the panel as
// a building selection. The count summary is the harmless fallback.
return SelectionContentKind::MultiBuilding;
}
return SelectionContentKind::MultiBuilding;
}
// A belt, tunnel entry or tunnel exit -- the types whose card is the clear action alone
// and therefore aggregates (REQ-UI-SELECTION-AGGREGATE). The splitter is deliberately
// excluded: it carries per-object output filters, which have no aggregate.
bool isAggregatableBeltType(BuildingType type)
{
return type == BuildingType::Belt
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit;
}
ContentKey chooseBuildingContent(const SelectionRequest& request, Simulation& sim)
{
const FactoryState& state = sim.getFactoryState();
if (request.buildings.size() == 1)
{
const BuildingId id = request.buildings.front();
const Building* building = findBuilding(state, id);
if (building)
{
return { getKindForType(building->type), false };
}
const ConstructionSite* site = findSite(state, id);
if (site)
{
return { getKindForType(site->type), true };
}
// The building went away under the panel; the selection is stale and there is
// nothing to show (REQ-UI-EMPTY-SELECTION).
return {};
}
// Several buildings aggregate into one card only when every part of that card
// aggregates (REQ-UI-SELECTION-AGGREGATE). Construction sites are excluded from the
// belt case: a site's card is its own construction progress, which several sites
// cannot share.
bool allAggregatableBelts = true;
for (BuildingId id : request.buildings)
{
const Building* building = findBuilding(state, id);
if (!building || !isAggregatableBeltType(building->type))
{
allAggregatableBelts = false;
break;
}
}
if (allAggregatableBelts)
{
return { SelectionContentKind::Belt, false };
}
return { SelectionContentKind::MultiBuilding, false };
}
ContentKey chooseFieldContent(const SelectionRequest& request, Simulation& sim)
{
// A full single-object card is shown for a lone actor, and for debris whether one
// piece or several -- debris is the field category's other aggregating content
// (REQ-UI-FIELD-MULTI-SELECTION, REQ-UI-SELECTION-AGGREGATE).
if (request.actors.size() == 1 && request.debris.empty())
{
EntityAdmin& admin = sim.getAdmin();
const entt::entity actor = request.actors.front();
if (admin.isValid(actor) && admin.hasAll<ShipIdentityComponent>(actor))
{
return { SelectionContentKind::Ship, false };
}
if (admin.isValid(actor) && admin.hasAll<StationBodyComponent>(actor))
{
return { SelectionContentKind::Station, false };
}
return {};
}
if (request.actors.empty() && !request.debris.empty())
{
return { SelectionContentKind::Debris, false };
}
return { SelectionContentKind::FieldMulti, false };
}
} // namespace
ContentKey chooseContent(const SelectionRequest& request, Simulation& sim)
{
if (request.isEmpty())
{
return {};
}
// Buildings win, so a non-empty building selection is the whole selection
// (REQ-UI-SELECTION-CATEGORIES).
if (!request.buildings.empty())
{
return chooseBuildingContent(request, sim);
}
return chooseFieldContent(request, sim);
}
SelectionContent* createContent(const ContentKey& key, const SelectionRequest& request,
const SelectionContext& context, QWidget* parent)
{
switch (key.kind)
{
case SelectionContentKind::None:
return nullptr;
case SelectionContentKind::RecipeProduction:
return new RecipeProductionContent(context, request, parent);
case SelectionContentKind::AutoProduction:
return new AutoProductionContent(context, request, parent);
case SelectionContentKind::Shipyard:
return new ShipyardContent(context, request, parent);
case SelectionContentKind::Storage:
return new StorageContent(context, request, parent);
case SelectionContentKind::Hq:
return new HqContent(context, request, parent);
case SelectionContentKind::Belt:
return new BeltContent(context, request, parent);
case SelectionContentKind::Splitter:
return new SplitterContent(context, request, parent);
case SelectionContentKind::MultiBuilding:
return new MultiBuildingContent(context, request, parent);
case SelectionContentKind::Ship:
return new ShipContent(context, request, parent);
case SelectionContentKind::Station:
return new StationContent(context, request, parent);
case SelectionContentKind::Debris:
return new DebrisContent(context, request, parent);
case SelectionContentKind::FieldMulti:
return new FieldMultiContent(context, request, parent);
}
return nullptr;
}

View File

@@ -1,70 +0,0 @@
#pragma once
#include <vector>
#include "entt/entity/entity.hpp"
#include "BuildingId.h"
#include "SelectionContext.h"
class QWidget;
class SelectionContent;
class Simulation;
// What is currently selected, in the selection panel's terms. The two categories are
// mutually exclusive (REQ-UI-SELECTION-CATEGORIES): either buildings is non-empty, or
// actors and/or debris are, never both.
struct SelectionRequest
{
std::vector<BuildingId> buildings;
std::vector<entt::entity> actors;
std::vector<entt::entity> debris;
bool isEmpty() const
{
return buildings.empty() && actors.empty() && debris.empty();
}
};
// One entry of the content catalog (REQ-UI-SELECTION-CONTENT). Selections that share an
// entry get the same content and differ only in the name and symbol in the header.
enum class SelectionContentKind
{
None, // nothing selected: the panel hides itself entirely
RecipeProduction, // Miner, Assembler
AutoProduction, // Smelter, Reprocessing Plant
Shipyard,
Storage, // Salvage Bay
Hq,
Belt, // Belt, Tunnel Entry, Tunnel Exit
Splitter,
MultiBuilding,
Ship,
Station,
Debris,
FieldMulti,
};
// Identifies the card on screen. The panel rebuilds when this changes and only refreshes
// otherwise, so a construction site finishing swaps the card while a progress counter
// running does not.
struct ContentKey
{
SelectionContentKind kind = SelectionContentKind::None;
bool isSite = false;
bool operator==(const ContentKey& other) const
{
return kind == other.kind && isSite == other.isSite;
}
bool operator!=(const ContentKey& other) const { return !(*this == other); }
};
// The card this selection calls for. This is where REQ-UI-SELECTION-AGGREGATE is
// decided: a multi-selection that aggregates resolves to the single-object kind, and
// everything else to one of the two count summaries.
ContentKey chooseContent(const SelectionRequest& request, Simulation& sim);
// Builds the card. Returns nullptr for SelectionContentKind::None.
SelectionContent* createContent(const ContentKey& key, const SelectionRequest& request,
const SelectionContext& context, QWidget* parent);

View File

@@ -1,29 +0,0 @@
#pragma once
struct GameConfig;
struct VisualsConfig;
class BuildingIconCache;
class ItemIconCache;
class Simulation;
// Everything a selection panel content reads that is not the selection itself: the
// simulation it queries live values from, the immutable config, and the window-wide
// rendering resources. Bundled so a content's constructor stays short and adding a
// shared resource does not touch every content (REQ-UI-SELECTION-CONTENT).
//
// Nothing here is owned: the whole struct is a view onto objects living in MainWindow
// and must not outlive it. Passed by const reference and copied into each content.
struct SelectionContext
{
Simulation* sim = nullptr;
const GameConfig* config = nullptr;
const VisualsConfig* visuals = nullptr;
ItemIconCache* itemIcons = nullptr;
BuildingIconCache* buildingIcons = nullptr;
// Whether debug draw is currently on (REQ-UI-DEBUG-DRAW), which the ship card shows
// its threat cost under (REQ-UI-SHIP-STATS-PANEL). A pointer rather than a copy
// because cards outlive a toggle: the panel owns the flag, so a card reading it per
// refresh always sees the current value without subscribing to the event itself.
const bool* debugDrawEnabled = nullptr;
};

View File

@@ -1,48 +0,0 @@
#include "SelectionNames.h"
#include <QObject>
#include "DisplayName.h"
QString getBuildingTypeName(BuildingType type)
{
// "Hq" would read as a word rather than an acronym through the generic conversion,
// and the player's own base deserves naming as such.
if (type == BuildingType::Hq)
{
return QObject::tr("Player HQ");
}
return QString::fromStdString(toDisplayName(buildingTypeId(type)));
}
QString getBehaviorLabel(BehaviorKind kind)
{
// Only the winning behavior is named; the salvage and repair cycles that run
// regardless of it are not behaviors here (REQ-UI-SHIP-BEHAVIOR).
switch (kind)
{
case BehaviorKind::Retreat: return QObject::tr("Retreating");
case BehaviorKind::Attack: return QObject::tr("Engaging");
case BehaviorKind::SalvageScrap:
case BehaviorKind::DeliverScrap: return QObject::tr("Salvaging");
case BehaviorKind::Repair: return QObject::tr("Repairing");
case BehaviorKind::Rally: return QObject::tr("Rallying");
case BehaviorKind::Standby: return QObject::tr("Standby");
case BehaviorKind::Advance: return QObject::tr("Advancing");
case BehaviorKind::None: break;
}
return QString();
}
QStringList getAllBehaviorLabels()
{
QStringList labels;
for (BehaviorKind kind : { BehaviorKind::Retreat, BehaviorKind::Attack,
BehaviorKind::SalvageScrap, BehaviorKind::Repair,
BehaviorKind::Rally, BehaviorKind::Standby,
BehaviorKind::Advance })
{
labels << getBehaviorLabel(kind);
}
return labels;
}

View File

@@ -1,21 +0,0 @@
#pragma once
#include <QString>
#include <QStringList>
#include "BehaviorKind.h"
#include "BuildingType.h"
// Display name of a building type for the selection panel's header and count rows
// (REQ-UI-SELECTION-CARD, REQ-UI-MULTI-SELECTION). The name is derived from the type's
// config id, so a new building type needs no entry here.
QString getBuildingTypeName(BuildingType type);
// Name of the behavior currently governing a ship, for the ship card's header slot
// (REQ-UI-SHIP-BEHAVIOR). Empty when no behavior has won yet, which shows no slot.
QString getBehaviorLabel(BehaviorKind kind);
// Every name getBehaviorLabel can return. A ship's behavior changes as it fights, so the
// header reserves room for the widest of these rather than letting the panel change
// width each time (REQ-UI-SELECTION-PANEL).
QStringList getAllBehaviorLabels();

View File

@@ -1,71 +0,0 @@
#include "ShipContent.h"
#include <QVBoxLayout>
#include "EntityAdmin.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "SelectedBehaviorComponent.h"
#include "SelectionNames.h"
#include "ShipIdentityComponent.h"
#include "ShipStatsCalculator.h"
#include "ShipStatsPanel.h"
#include "Simulation.h"
#include "ThreatCostCalculator.h"
ShipContent::ShipContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, std::nullopt, parent)
, m_entity(request.actors.front())
{
m_statsPanel = new ShipStatsPanel(context.config, this);
getRuntimeLayout()->addWidget(m_statsPanel);
// A ship's behavior changes as it fights, so the header holds room for the longest
// name rather than the panel resizing under the player each time it does.
reserveSlotFor(getAllBehaviorLabels());
EntityAdmin& admin = context.sim->getAdmin();
if (admin.isValid(m_entity) && admin.hasAll<ShipIdentityComponent>(m_entity))
{
setIdentity(QPixmap(), tr("Ship: %1").arg(QString::fromStdString(
admin.get<ShipIdentityComponent>(m_entity).schematicId)));
}
}
void ShipContent::refreshRuntime()
{
EntityAdmin& admin = getContext().sim->getAdmin();
if (!admin.isValid(m_entity) || !admin.hasAll<HealthComponent>(m_entity))
{
// The ship died or despawned. GameWorldView prunes it from the selection and
// re-emits (REQ-UI-ENTITY-CLICK-SELECT), which rebuilds the card; this only has
// to avoid reading it in the meantime.
return;
}
const HealthComponent& health = admin.get<HealthComponent>(m_entity);
if (health.hp <= 0.0f)
{
return;
}
const ShipStats stats = buildShipStatsFromEntity(admin, m_entity);
m_statsPanel->refreshFromLive(stats, health.hp);
m_statsPanel->setDebugDrawEnabled(*getContext().debugDrawEnabled);
// The behavior is the header's right slot rather than a stat row, so it reads as
// what the ship is doing rather than as another number (REQ-UI-SHIP-BEHAVIOR).
setSlot(QColor(), getBehaviorLabel(
admin.get<SelectedBehaviorComponent>(m_entity).winner));
const ShipIdentityComponent& identity = admin.get<ShipIdentityComponent>(m_entity);
const ShipDef* schematicDef =
getContext().config->ships.findShipDef(identity.schematicId);
if (schematicDef)
{
m_statsPanel->setThreatCost(calculateShipThreatCost(
getContext().config->threatCosts, *getContext().config,
schematicDef->id, schematicDef->defaultModules));
}
}

View File

@@ -1,28 +0,0 @@
#pragma once
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class ShipStatsPanel;
// The card for one selected ship (REQ-UI-SHIP-STATS-PANEL): its live hull stats and the
// summaries of the capability modules it carries, computed from what is actually
// installed (REQ-MOD-STAT-CALC). Applies to player and enemy ships alike
// (REQ-UI-ENTITY-CLICK-SELECT).
class ShipContent : public SelectionContent
{
Q_OBJECT
public:
ShipContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
entt::entity m_entity;
ShipStatsPanel* m_statsPanel;
};

View File

@@ -1,102 +0,0 @@
#include "ShipyardContent.h"
#include <QPushButton>
#include <QVBoxLayout>
#include "BuildingTarget.h"
#include "EventManager.h"
#include "GameConfig.h"
#include "LayoutDialogRequestedEvent.h"
#include "ProductionRules.h"
#include "RecipeSelectionControl.h"
#include "SectionBox.h"
#include "ShipLayoutPreview.h"
ShipyardContent::ShipyardContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
m_schematicControl = new RecipeSelectionControl(context, getBuildingId(),
BuildingType::Shipyard, this);
m_layoutSection = new SectionBox(tr("Layout"), this);
m_layoutPreview = new ShipLayoutPreview(m_layoutSection);
m_configureButton = new QPushButton(tr("Configure Layout"), m_layoutSection);
m_layoutSection->getContentLayout()->addWidget(m_layoutPreview);
m_layoutSection->getContentLayout()->addWidget(m_configureButton);
// Ahead of the recipe summary the base adds, so the card reads schematic, layout,
// then what one ship costs.
getConfigurationLayout()->insertWidget(0, m_schematicControl);
getConfigurationLayout()->insertWidget(1, m_layoutSection);
const BuildingId id = getBuildingId();
connect(m_configureButton, &QPushButton::clicked, this, [id]() {
EventManager::getInstance()->sendEventImmediately(
std::make_shared<LayoutDialogRequestedEvent>(id));
});
}
void ShipyardContent::refreshControls(const BuildingTarget& target)
{
m_schematicControl->setRecipeId(target.recipeId);
// The preview and Configure button are always shown for a shipyard and are only
// enabled once a schematic is selected (REQ-MOD-UI-PREVIEW). The schematic arrives
// by queued command, so this refresh is what picks it up rather than the click that
// chose it.
const ShipDef* shipDef = target.recipeId.empty()
? nullptr
: getContext().config->ships.findShipDef(target.recipeId);
const bool hasSchematic = shipDef && !shipDef->layout.empty();
if (hasSchematic)
{
m_layoutPreview->setShipAndLayout(
shipDef->layout,
target.shipLayout.has_value() ? *target.shipLayout : ShipLayoutConfig(),
&getContext().config->modules);
}
else
{
m_layoutPreview->showPlaceholder();
}
m_layoutPreview->setEnabled(hasSchematic);
m_configureButton->setEnabled(hasSchematic);
}
BufferedBuildingContent::CycleInfo ShipyardContent::getCycleInfo(
const BuildingTarget& target) const
{
CycleInfo info;
const ShipDef* shipDef = target.recipeId.empty()
? nullptr
: getContext().config->ships.findShipDef(target.recipeId);
if (!shipDef)
{
return info;
}
// The schematic's materials plus every placed module's, which is also what sized the
// input buffers (REQ-BLD-SHIPYARD). The simulation owns that sum, so the panel asks
// it rather than adding the modules up a second time.
info.perCycleInputs = computeShipyardRequiredMaterials(
*getContext().config, target.recipeId, target.shipLayout);
// A shipyard's output is the ship itself, which never lands in an item buffer -- so
// the summary shows one ship rather than an item id.
info.durationSeconds = shipDef->schematic.productionTimeSeconds;
if (target.shipLayout.has_value())
{
for (const PlacedModule& placed : target.shipLayout->placedModules)
{
const ModuleDef* moduleDef =
getContext().config->modules.findModuleDef(placed.moduleId);
if (moduleDef)
{
info.durationSeconds += moduleDef->productionTimeSeconds;
}
}
}
info.runsProduction = true;
return info;
}

Some files were not shown because too many files have changed in this diff Show More