5 Commits

Author SHA1 Message Date
246cfc3935 give the selection cards their own parts instead of label blobs
The cards were assembled from plain labels carrying whole blocks of text.
Replace those with the widget vocabulary the requirements describe, so a part
means the same thing wherever it appears and a card is a list of parts rather
than a string builder.

The parts, all free of Simulation and GameConfig -- they take prepared values,
and the contents work out what those are:
- StatRow, BarRow, SectionBox: label/value line, captioned fill bar, captioned
  group. The bar is one part for three things: construction progress,
  production progress, and HP.
- ItemChip / ItemChipRow: buffered items as icon, count and sub-line
  (REQ-UI-SINGLE-SELECTION). An input chip carries its per-cycle amount, an
  output chip its count against the buffer capacity. The chips are rebuilt only
  when the set of items changes, so a 30 Hz refresh moves numbers rather than
  widgets.
- RecipeSummaryRow: inputs, arrow, outputs, cycle time (REQ-UI-RECIPE-SUMMARY),
  which is now the panel's only display of the cycle time.
- CountRow, StatusPill, EmptyNote.

Behaviour that changed with them:
- The station card shows damage, range and fire rate as the requirement asks
  (REQ-UI-STATION-STATS-PANEL) rather than the combined DPS it showed before.
- A ship's behaviour moves from a stats row into the card header
  (REQ-UI-SHIP-BEHAVIOR). ShipStatsPanel keeps setBehavior for the balancing
  tool's inspect window, which has no header to put it in.
- A construction site's card shows a progress bar and the "no buffers until
  built" note (REQ-UI-SELECTION-CARD), and now also its recipe summary, since
  that is configuration and a site carries it (REQ-BLD-SITE-CONFIG). Costing a
  shipyard site's schematic needed computeShipyardRequiredMaterials to take a
  stored configuration as well as a live building -- one overload, so the
  module sum still exists once.

ShipStatsPanel is rebuilt on StatRow, BarRow and SectionBox, so the selection
card, the layout dialog's design preview and the balancing tool read alike.
Those three parts are compiled into the balancing target, which does not link
the ui library; keeping them sim-free is what makes that possible, and the
build enforces it.

Build clean, 541 tests pass, app and balancing tool both run with no Qt
warnings. Visual check pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-07 12:37:11 +02:00
89e984ec76 split the selection panel into one card per kind of selection
The panel rendered every selection out of a single pool of member widgets,
hidden and shown per branch, so each build path had to remember to hide the
other branches' widgets. That coupling produced the two defects fixed in
668ce0f, and the per-type detail the requirements now ask for would only add
more of it.

The pool is gone. SelectionPanel keeps the category arbitration, the float and
the hide-when-empty behaviour, and hosts exactly one SelectionContent at a
time; SelectionContentFactory picks which one from the selection alone. Each
card is one row of the catalog in REQ-UI-SELECTION-CONTENT and owns only its
own widgets.

The card structure (REQ-UI-SELECTION-CARD) lives in the base class: a header
with an identity symbol, a name and one right slot, then a configuration group
and a runtime group. A construction site keeps its configuration and has its
whole runtime group replaced by the construction progress, decided once there
rather than in every card (REQ-BLD-SITE-CONFIG).

Also implemented here:
- REQ-UI-SELECTION-STATUS: the header status dot, taken from the simulation's
  own getProductionStatus() so the panel and the world's status light cannot
  disagree.
- REQ-UI-SELECTION-AGGREGATE: belt-subsystem tiles and debris-only selections
  collapse into one card with a count instead of a count summary.
- REQ-UI-HQ-PANEL: the HQ shows the global block stock and its HP, neither of
  which is a buffer.
- BuildingIconCache, extracted from BuildButtonBar's file-local chip loading so
  the card headers and the build buttons rasterize the same SVGs once.

FieldSelectionPanel is deleted: ships, stations, debris and the field count
summary are four more cards in the same factory, so the two-panel arbitration
collapses into one decision.

The card parts are still today's labels and buttons; the item chips, bars,
recipe summary and stat rows follow.

Build clean, 541 tests pass, app runs with no Qt warnings. Visual check
pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-07 12:18:33 +02:00
90e40dddbc define the selection panel as a catalog of per-selection contents
The panel's content requirements described one panel that changes shape.
Restate them as one card structure plus a catalog of contents picked by
what is selected, so each selection has a named content and the parts
mean the same thing wherever they appear.

New:
- REQ-UI-SELECTION-CARD: header (symbol, name, one optional right slot),
  configuration group, runtime group. A construction site replaces the
  whole runtime group with a Construction bar and keeps its configuration
  group, per REQ-BLD-SITE-CONFIG.
- REQ-UI-SELECTION-CONTENT: the catalog table.
- REQ-UI-SELECTION-STATUS: the header status dot, derived from
  REQ-UI-STATUS-LIGHT's evaluation rather than a second definition.
- REQ-UI-SELECTION-AGGREGATE: a homogeneous multi-selection collapses into
  one content with a count, but only where every part aggregates -- belt
  subsystem tiles and debris. Splitters mixed with belts and several
  production buildings fall back to the count summary.
- REQ-UI-RECIPE-SUMMARY: the inputs -> outputs, duration line, including a
  shipyard's module contributions.
- REQ-UI-HQ-PANEL: global block stock plus HP, because blocks bypass the
  buffers into the global stock (REQ-HQ-BELT-INPUT).

Rewritten: REQ-UI-SINGLE-SELECTION (item chips; an output chip's
denominator is now the buffer capacity, not the per-cycle amount),
REQ-UI-PRODUCTION-PROGRESS (bar, and no longer the cycle time's home),
REQ-UI-MULTI-SELECTION (count rows and a total cost row), REQ-UI-BELT-CLEAR,
REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL, REQ-UI-SHIP-BEHAVIOR
(moves into the header slot), REQ-UI-FIELD-MULTI-SELECTION and
REQ-UI-DEBRIS-PANEL (debris-only selections now aggregate).

Requirements only; no code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-07 09:59:26 +02:00
668ce0fcb8 float the selection panel over the game world instead of a side column
Implements REQ-UI-SELECTION-PANEL and the full-width REQ-UI-WORLD-SIZE /
REQ-UI-HEADER: MainWindow drops the 25% side column and its 75/25 math, so
the header bar and world view span the window, and the panel joins the
build button bar as a widget floating over the world.

The panel follows the bar's pattern -- an opaque sibling built after the
world view, so it sits above the vignettes, below the dim overlay, and
swallows the mouse events that would otherwise reach the world. It sizes
itself to its content within a band the window hands it (the world view
less the bar's strip, so the bar never has to move for it), right-aligned
and centered in that band, and scrolls once the content outgrows it. Its
content moved into a scroll area for that; the width is capped at 320 px
because the wrapped labels and the splitter filter lists have no natural
width of their own. With nothing selected the panel now hides entirely
rather than showing an empty box (REQ-UI-EMPTY-SELECTION).

Two defects that content sizing exposed: buildEmpty() left the selected
ids behind when the building vanished under the panel, which would have
held an empty panel on screen, and buildMulti() let a shipyard's layout
preview survive from a previous single selection (REQ-UI-MULTI-SELECTION).

Renames SelectedBuildingPanel to SelectionPanel throughout, matching the
requirements: the panel has long shown ships, stations, and debris too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-06 21:58:36 +02:00
7ddb4a1bb5 float the selection panel over the game world instead of a side column
Remove the 25% side panel column: the header bar and game world view now
span the full window width, and the selection panel floats over the world
at its right edge, content-sized and shown only while something is
selected.

REQ-UI-PANEL-COLUMN is replaced by REQ-UI-SELECTION-PANEL, which defines
the panel's geometry, visibility, overlay, and input rules. The panel is
centered within the view height less its margins and the build button
bar's strip, so the bar never has to move out of its way. Rename the
"selected building panel" to "selection panel" throughout, since it has
long shown ships, stations, and debris too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-06 21:17:43 +02:00
88 changed files with 4459 additions and 1841 deletions

View File

@@ -124,7 +124,8 @@ 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, selected building panel, build button bar. 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`.

View File

@@ -390,11 +390,11 @@ Reshape mutations to flow through one path; behaviour unchanged.
before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset` before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset`
triggers the view reset. triggers the view reset.
- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues - Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues
directly; `MainWindow` and `SelectedBuildingPanel` emit `CommandRequestedEvent` (carrying a directly; `MainWindow` and `SelectionPanel` emit `CommandRequestedEvent` (carrying a
`shared_ptr<const Command>`) which `GameWorldView` subscribes to and enqueues. `shared_ptr<const Command>`) which `GameWorldView` subscribes to and enqueues.
- **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`; - **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`;
`Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`, `Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`,
`SelectedBuildingPanel.cpp`; new `CommandTest.cpp`. `SelectionPanel.cpp`; new `CommandTest.cpp`.
- **Exit criteria:** game plays identically (including build-while-paused); determinism test - **Exit criteria:** game plays identically (including build-while-paused); determinism test
still passes; `[command]` equivalence tests pass; no production call site can mutate the sim still passes; `[command]` equivalence tests pass; no production call site can mutate the sim
directly (compile-enforced: the `Simulation` mutators are private, tests excepted via directly (compile-enforced: the `Simulation` mutators are private, tests excepted via

View File

@@ -146,7 +146,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-BLD-DECON-QUEUE: Fully-built factory buildings marked for demolition (REQ-BLD-DECONSTRUCT) enter a **deconstruction queue** that is processed one building at a time and runs in parallel with the construction queue (REQ-BLD-QUEUE) — the two queues advance independently and simultaneously. Each building takes `world.toml [world].deconstruction_time_seconds` (default 0.1) to deconstruct, the same duration for every building type. When a building's deconstruction completes it is removed from the world and its refund is credited (REQ-BLD-DECONSTRUCT). A building **stops operating the moment it enters the queue**: it runs no production and transports no items, and no longer participates as a live building (its tunnel pairing is re-evaluated as if it were gone, REQ-BLD-TUNNEL-PAIR), but it still physically occupies its tiles until removed, so those tiles stay blocked for placement. A queued building can be taken back out of the deconstruction queue before it is removed (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX) — including the one currently being deconstructed; doing so discards any deconstruction progress, credits no refund, and the building resumes operating (and re-pairs, REQ-BLD-TUNNEL-PAIR). Construction sites never enter the deconstruction queue (REQ-BLD-DECONSTRUCT). Every building in the deconstruction queue is rendered with the deconstruct tint — the `visuals.toml [overlays].deconstruct_tint` color, the same tint applied to a building hovered in deconstruct mode (REQ-UI-DECONSTRUCT-BORDER) — so queued buildings are visually distinct. - REQ-BLD-DECON-QUEUE: Fully-built factory buildings marked for demolition (REQ-BLD-DECONSTRUCT) enter a **deconstruction queue** that is processed one building at a time and runs in parallel with the construction queue (REQ-BLD-QUEUE) — the two queues advance independently and simultaneously. Each building takes `world.toml [world].deconstruction_time_seconds` (default 0.1) to deconstruct, the same duration for every building type. When a building's deconstruction completes it is removed from the world and its refund is credited (REQ-BLD-DECONSTRUCT). A building **stops operating the moment it enters the queue**: it runs no production and transports no items, and no longer participates as a live building (its tunnel pairing is re-evaluated as if it were gone, REQ-BLD-TUNNEL-PAIR), but it still physically occupies its tiles until removed, so those tiles stay blocked for placement. A queued building can be taken back out of the deconstruction queue before it is removed (REQ-BLD-DECONSTRUCT-CLICK, REQ-BLD-DECONSTRUCT-BOX) — including the one currently being deconstructed; doing so discards any deconstruction progress, credits no refund, and the building resumes operating (and re-pairs, REQ-BLD-TUNNEL-PAIR). Construction sites never enter the deconstruction queue (REQ-BLD-DECONSTRUCT). Every building in the deconstruction queue is rendered with the deconstruct tint — the `visuals.toml [overlays].deconstruct_tint` color, the same tint applied to a building hovered in deconstruct mode (REQ-UI-DECONSTRUCT-BORDER) — so queued buildings are visually distinct.
- REQ-BLD-DECONSTRUCT-CLICK: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), left-clicking a placed factory building or construction site in the game world marks it for demolition, following the rules of REQ-BLD-DECONSTRUCT: a fully-built building is added to the deconstruction queue (REQ-BLD-DECON-QUEUE), and a construction site is removed instantly with the full refund. Left-clicking a fully-built building that is **already in the deconstruction queue** instead removes it from the queue (un-queues it, REQ-BLD-DECON-QUEUE), with no refund; repeated clicks on the same building therefore alternate between queueing and un-queueing it. Clicking a building that cannot be deconstructed (the HQ or a player defence station, per REQ-BLD-DECONSTRUCT), or clicking empty world space, has no effect. Deconstruct mode stays active after each action so the player can continue without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON). - REQ-BLD-DECONSTRUCT-CLICK: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), left-clicking a placed factory building or construction site in the game world marks it for demolition, following the rules of REQ-BLD-DECONSTRUCT: a fully-built building is added to the deconstruction queue (REQ-BLD-DECON-QUEUE), and a construction site is removed instantly with the full refund. Left-clicking a fully-built building that is **already in the deconstruction queue** instead removes it from the queue (un-queues it, REQ-BLD-DECON-QUEUE), with no refund; repeated clicks on the same building therefore alternate between queueing and un-queueing it. Clicking a building that cannot be deconstructed (the HQ or a player defence station, per REQ-BLD-DECONSTRUCT), or clicking empty world space, has no effect. Deconstruct mode stays active after each action so the player can continue without re-entering the mode; it is exited via the Q toggle (REQ-UI-HOTKEYS) or the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON).
- REQ-BLD-DECONSTRUCT-BOX: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT). On mouse up, following the rules of REQ-BLD-DECONSTRUCT: every construction site covered by the box is removed instantly with the full refund; and among the fully-built deconstructible buildings covered by the box, if **all** of them are already in the deconstruction queue they are all removed from it (un-queued, REQ-BLD-DECON-QUEUE), otherwise every covered building not yet in the queue is added to the deconstruction queue (already-queued ones stay). Buildings that cannot be deconstructed (the HQ and player defence stations, per REQ-BLD-DECONSTRUCT) are excluded from the box demolition; ships and defence stations are never affected. - REQ-BLD-DECONSTRUCT-BOX: While in deconstruct mode (REQ-UI-HOTKEYS, REQ-UI-DECONSTRUCT-BUTTON), the player can click and drag a selection box in the game world. A selection rectangle is drawn while dragging, using the same box-drag gesture and coverage semantics as the multi-select box (REQ-UI-MULTI-SELECT). On mouse up, following the rules of REQ-BLD-DECONSTRUCT: every construction site covered by the box is removed instantly with the full refund; and among the fully-built deconstructible buildings covered by the box, if **all** of them are already in the deconstruction queue they are all removed from it (un-queued, REQ-BLD-DECON-QUEUE), otherwise every covered building not yet in the queue is added to the deconstruction queue (already-queued ones stay). Buildings that cannot be deconstructed (the HQ and player defence stations, per REQ-BLD-DECONSTRUCT) are excluded from the box demolition; ships and defence stations are never affected.
- REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner or Assembler (REQ-UI-SELECT-BUTTON), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same Selected Building Panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.) - REQ-BLD-SITE-CONFIG: A construction site — a building that has been placed but is still queued or under construction (REQ-BLD-QUEUE) — can be selected and configured exactly like the equivalent operational building, before it finishes building. Whatever configuration the building type supports is available on the site: the recipe for a Miner or Assembler (REQ-UI-SELECT-BUTTON), the produced-ship schematic and its module layout for a Shipyard (REQ-UI-SELECT-BUTTON, REQ-MOD-UI-PREVIEW, REQ-MOD-UI-DIALOG), and the output filters for a Splitter (REQ-BLD-SPLITTER) — all set through the same selection panel controls (REQ-UI-CONFIG-INLINE). Only currently unlocked recipes and schematics are offered, exactly as for operational buildings (REQ-LOCK-UI-RECIPE, REQ-LOCK-UI-SCHEMATIC, REQ-LOCK-UI-SPLITTER). The configuration is stored on the construction site and carries over unchanged when construction completes, so the building becomes operational already configured. A construction site has no input/output buffers and runs no production cycle, so the buffer and production-progress portions of the panel (REQ-UI-SINGLE-SELECTION, REQ-UI-PRODUCTION-PROGRESS) are not shown for it; only its construction progress (REQ-UI-CONSTRUCTION-PROGRESS) and its configuration controls appear. (Blueprint placement already applies a stored recipe or schematic to a construction site on placement per REQ-UI-BLUEPRINT-PLACE; this requirement additionally lets the player set or change that configuration directly on an existing site.)
## Building Types ## Building Types
@@ -157,7 +157,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. Confirming a layout identical to the one already configured is not a change and cancels nothing (REQ-MAT-INPUT-BUFFER). - REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. Confirming a layout identical to the one already configured is not a change and cancels nothing (REQ-MAT-INPUT-BUFFER).
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts. - REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
- REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR). - REQ-BLD-BELT: **Belt** (1×1): Transports items. A belt tile has one direction (N, S, E, W) set at placement (modified by rotation). Curved belts are auto-derived: when a belt tile's outgoing direction leads into another belt whose direction is orthogonal, the downstream belt is rendered and behaves as a curve. Belt speed is defined in `world.toml [world].belt_speed_tiles_per_second` (REQ-GW-BELT-SPEED). A belt accepts items only through a non-output edge (REQ-MAT-ACCEPT-DIR).
- REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selected building panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules: - REQ-BLD-SPLITTER: **Splitter** (1×1): Distributes incoming items between two output directions. Incoming items are accepted only through the splitter's non-output edges (REQ-MAT-ACCEPT-DIR). Each output can optionally have a filter (a list of item types), configurable via the selection panel; only implicitly unlocked item types are available as filter options (REQ-LOCK-UI-SPLITTER). Routing rules:
- An item matching only one output's filter is routed to that output. - An item matching only one output's filter is routed to that output.
- An item matching both outputs' filters is distributed by strict alternation between those outputs. - An item matching both outputs' filters is distributed by strict alternation between those outputs.
- An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved. - An item matching neither output's filter is routed to the unfiltered output. If both outputs have a filter and the item matches neither, the splitter stalls and moves no items until the situation is resolved.
@@ -306,7 +306,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
### Module UI ### Module UI
- REQ-MOD-UI-PREVIEW: For a selected shipyard (operational building or construction site), the selected building panel always shows a small non-interactive **ship layout preview** widget below the schematic selection button (REQ-UI-SELECT-BUTTON) and a "Configure" button below the preview. Both are **disabled while no schematic is selected**, and enabled once one is; the preview then shows an empty placeholder in place of a layout grid. When a schematic is selected, the preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` with the module's `glyph` character. For non-shipyard buildings, neither the preview nor the "Configure" button is shown. - REQ-MOD-UI-PREVIEW: For a selected shipyard (operational building or construction site), the selection panel always shows a small non-interactive **ship layout preview** widget below the schematic selection button (REQ-UI-SELECT-BUTTON) and a "Configure" button below the preview. Both are **disabled while no schematic is selected**, and enabled once one is; the preview then shows an empty placeholder in place of a layout grid. When a schematic is selected, the preview renders the ship's layout grid at a reduced scale: buildable cells without a module are shown as white, non-buildable cells are shown as black, and cells occupied by a module are shown in that module's `fill_color` with the module's `glyph` character. For non-shipyard buildings, neither the preview nor the "Configure" button is shown.
- REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal. While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened. - REQ-MOD-UI-DIALOG: Clicking the "Configure" button opens the **layout configuration dialog** as a modal. While the dialog is open, the game is paused (speed set to 0×). On close, the game speed is restored to what it was before the dialog was opened.
The dialog contains: The dialog contains:
@@ -314,7 +314,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
- **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL). - **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL).
- **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. Each module button shows the module id and its glyph. - **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. Each module button shows the module id and its glyph.
- **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). - **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD).
- **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selected building panel are recalculated, and the ship layout preview is refreshed. - **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selection panel are recalculated, and the ship layout preview is refreshed.
- REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse. - REQ-MOD-UI-EMPTY-PULSE: While a module is selected for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG), the empty buildable cells of the layout grid pulse smoothly around their normal fill shade, oscillating between a slightly darker and a slightly brighter shade at approximately 1 Hz (one full cycle per second), to draw the player's attention to where the module can be placed. All empty buildable cells pulse in phase. When no module is selected for placement (including remove mode), empty buildable cells render at their normal static shade. Non-buildable cells and cells occupied by a placed module do not pulse.
@@ -428,24 +428,24 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
### Layout ### Layout
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) holding the selected building panel. The build button bar (REQ-UI-BUILD-BAR) is not part of either column — it floats over the game world at its bottom center. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG): 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):
``` ```
+--------------------------------------+--------------+ +-----------------------------------------------------------+
| Header Bar | | | Header Bar |
+--------------------------------------+ Selected | +-----------------------------------------------------------+
| | Building | | +-----------+ |
| | Panel | | | Selection | |
| Game World | | | Game World | Panel | |
| | | | +-----------+ |
| | | | |
| +------------------+ | | | +------------------+ |
| | Build Button Bar | | | | | Build Button Bar | |
+--------+------------------+----------+--------------+ +------------------+------------------+---------------------+
(75% width) (25% width) (full window width)
``` ```
- REQ-UI-HEADER: The header bar spans the width of the game world column (75% of the screen width) and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right. - REQ-UI-HEADER: The header bar spans the full width of the game window and always shows the elapsed survival time, the current global building blocks stock, and the artifact count (REQ-WIN-ARTIFACT-COUNT) displayed as `Artifacts: x/y` (where `x` is the current artifact count and `y` is `world.toml [world].artifact_win_count`) on the left, the boss wave counter and boss countdown (REQ-UI-BOSS-STATUS) and an asteroid expansion button (REQ-UI-EXPAND-BUTTON) to the left of the speed buttons, and game speed controls on the right.
- REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The hover tooltip (REQ-UI-BLOCKS-TOOLTIP) applies in either form. - REQ-UI-BLOCKS-ICON: In the header bar (REQ-UI-HEADER), the global building blocks stock is displayed as `Stock: <n>` followed by the `building_block` item icon (REQ-UI-ITEM-ICON) — e.g. `Stock: 200` then a small block icon — replacing the `Building Blocks: <n>` text label. The icon is sized to the header text height. When no icon file exists for `building_block` (a missing icon is not an error, REQ-UI-ITEM-ICON), the display falls back to the `Stock: <n> Blocks` text. The hover tooltip (REQ-UI-BLOCKS-TOOLTIP) applies in either form.
- REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP). - REQ-UI-BLOCKS-TOOLTIP: The header bar's building blocks stock display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].building_blocks_tooltip` — intended to tell the player what building blocks are used for and how to obtain them. If the field is unset, the stock display shows no tooltip. This tooltip is distinct from the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
- REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP). - REQ-UI-ARTIFACTS-TOOLTIP: The header bar's artifact count display (REQ-UI-HEADER) shows a hover tooltip with the descriptive text defined in `world.toml [world].artifact_tooltip` — intended to tell the player what artifacts are, how they are obtained (REQ-DEF-SCHEMATIC-DROP), and that collecting `world.toml [world].artifact_win_count` of them wins the game (REQ-WIN-ARTIFACT-COUNT). If the field is unset, the artifact count display shows no tooltip. This tooltip is distinct from the building blocks tooltip (REQ-UI-BLOCKS-TOOLTIP) and the build/module button tooltips (REQ-UI-BUILD-TOOLTIP, REQ-MOD-UI-MODULE-TOOLTIP).
@@ -454,9 +454,14 @@ The screen is divided into two columns: a main column (75% width) containing the
- 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 height below the header bar in the main column (75% of the screen width). - 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-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It holds a single panel filling that full height: the selected building panel. The build buttons are not part of this column; they float over the game world (REQ-UI-BUILD-BAR). Blueprints are not part of this column either; they are reached through the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG). - REQ-UI-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.
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
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.
- **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 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).
- 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
@@ -518,24 +523,64 @@ The screen is divided into two columns: a main column (75% width) containing the
- **Quit** — closes the application. - **Quit** — closes the application.
Pressing Escape while the escape menu is open is equivalent to clicking Continue. Pressing Escape while the escape menu is open is equivalent to clicking Continue.
### Selected Building Panel ### Selection Panel
- REQ-UI-EMPTY-SELECTION: When nothing is selected (no building, construction site, ship, defence station, or piece of debris), the panel is empty. 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-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-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG). - REQ-UI-SELECTION-CARD: **Card structure.** Every panel content is a card with the same three parts, top to bottom:
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown. - **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.
- **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). 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). The configuration group is 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, each shown only while it holds something (REQ-UI-SELECTION-CARD). For a selected construction site the buffer sections are omitted (REQ-BLD-SITE-CONFIG).
- 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 none while idle. 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**: 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, 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-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-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-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-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD). - REQ-UI-SELECT-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 selected building 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 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-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-HQ-PANEL: When the HQ is selected, the panel shows 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 — and the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS). 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 selected building 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-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-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: - 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:
- **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).
@@ -543,16 +588,17 @@ The screen is divided into two columns: a main column (75% width) containing the
- **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 selected building 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-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-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-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-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 exactly one piece of debris is selected (and no actors, REQ-UI-FIELD-MULTI-SELECTION), the selected building 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). - 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).
### 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 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.
@@ -563,7 +609,7 @@ The screen is divided into two columns: a main column (75% width) containing the
### 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). There is no blueprint panel in the side panel column (REQ-UI-PANEL-COLUMN). (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.) 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.)
- REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). The dialog prompts the player to enter a name and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape, or closing the dialog — closes it with no effect and does not open the blueprint selection dialog. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others. - REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). The dialog prompts the player to enter a name and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape, or closing the dialog — closes it with no effect and does not open the blueprint selection dialog. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others.
@@ -634,7 +680,7 @@ Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from
- The ghost is drawn in a distinct **transfer** color read from `visuals.toml [overlays]`, overriding both the per-building coloring and the "invalid" color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The position counts as valid despite the occupied tiles (REQ-BLD-PLACE-VALID condition (b)). - The ghost is drawn in a distinct **transfer** color read from `visuals.toml [overlays]`, overriding both the per-building coloring and the "invalid" color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The position counts as valid despite the occupied tiles (REQ-BLD-PLACE-VALID condition (b)).
- **Left-clicking transfers the configuration** to the existing building or site, making the target's settings **identical to the source's**: the recipe ID (Miner, Assembler), the schematic ID together with the ship layout (Shipyard), or the two output filters (Splitter). No construction site is placed, no building blocks are consumed (it is excluded from the total cost of REQ-UI-BLUEPRINT-PLACE), and the target's **rotation is not changed** — a transfer never rotates. - **Left-clicking transfers the configuration** to the existing building or site, making the target's settings **identical to the source's**: the recipe ID (Miner, Assembler), the schematic ID together with the ship layout (Shipyard), or the two output filters (Splitter). No construction site is placed, no building blocks are consumed (it is excluded from the total cost of REQ-UI-BLUEPRINT-PLACE), and the target's **rotation is not changed** — a transfer never rotates.
- The transfer is a **full mirror, including the absence of a setting**: where the blueprint stores no configuration for a field (REQ-UI-BLUEPRINT-STORAGE stores nothing for an unselected recipe or schematic, and no filter lists for a splitter whose filters were empty at capture time), the target's corresponding setting is **cleared** rather than left as it was. So a splitter captured with no filters clears the target splitter's filters back to accept-all, and a miner captured with no recipe selected clears the target miner's recipe. This holds for every blueprint size, so a constellation captured from unconfigured buildings clears the settings of every matching building it is dropped on. - The transfer is a **full mirror, including the absence of a setting**: where the blueprint stores no configuration for a field (REQ-UI-BLUEPRINT-STORAGE stores nothing for an unselected recipe or schematic, and no filter lists for a splitter whose filters were empty at capture time), the target's corresponding setting is **cleared** rather than left as it was. So a splitter captured with no filters clears the target splitter's filters back to accept-all, and a miner captured with no recipe selected clears the target miner's recipe. This holds for every blueprint size, so a constellation captured from unconfigured buildings clears the settings of every matching building it is dropped on.
- The transfer has the same effects as making that selection through the selected building panel, clearing included: buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD. It inherits the no-op rule of REQ-MAT-INPUT-BUFFER with them: a transfer onto a building whose settings already match the source changes nothing at all — no buffers cleared, no production cycle cancelled, no construction progress lost — so repeatedly clicking already-matching buildings is harmless. Each field is judged on its own, so transferring an identical recipe with a differing layout affects only the layout. The layout configuration dialog does not auto-open (REQ-MOD-UI-AUTO-DIALOG). - The transfer has the same effects as making that selection through the selection panel, clearing included: buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD. It inherits the no-op rule of REQ-MAT-INPUT-BUFFER with them: a transfer onto a building whose settings already match the source changes nothing at all — no buffers cleared, no production cycle cancelled, no construction progress lost — so repeatedly clicking already-matching buildings is harmless. Each field is judged on its own, so transferring an identical recipe with a differing layout affects only the layout. The layout configuration dialog does not auto-open (REQ-MOD-UI-AUTO-DIALOG).
- Unlock gating matches placement (REQ-UI-BLUEPRINT-PLACE): a stored schematic is applied only if it is currently unlocked, and locked recipe IDs and splitter filter entries for locked item types are handled per REQ-LOCK-UI-BLUEPRINT. - Unlock gating matches placement (REQ-UI-BLUEPRINT-PLACE): a stored schematic is applied only if it is currently unlocked, and locked recipe IDs and splitter filter entries for locked item types are handled per REQ-LOCK-UI-BLUEPRINT.
- Both operational buildings and construction sites are transfer targets (REQ-BLD-SITE-CONFIG); a configuration applied to a site carries over unchanged when it finishes building. - Both operational buildings and construction sites are transfer targets (REQ-BLD-SITE-CONFIG); a configuration applied to a site carries over unchanged when it finishes building.
- After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn. - After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn.

View File

@@ -10,7 +10,12 @@ 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 "${CMAKE_CURRENT_SOURCE_DIR}/ui") set(TARGET_UI_INCLUDE_DIRS
"${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")
@@ -78,6 +83,7 @@ unset(SRCS)
set(HDRS) set(HDRS)
set(SRCS) set(SRCS)
set(UI_INCLUDE_PATH)
add_subdirectory(ui) add_subdirectory(ui)
@@ -106,6 +112,7 @@ 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,6 +7,12 @@ 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
@@ -26,6 +32,10 @@ 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

@@ -45,9 +45,17 @@ 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(b.recipeId); const ShipDef* shipDef = config.ships.findShipDef(recipeId);
if (!shipDef) if (!shipDef)
{ {
return requiredMaterials; return requiredMaterials;
@@ -56,9 +64,9 @@ computeShipyardRequiredMaterials(const GameConfig& config, const Building& b)
{ {
requiredMaterials[ing.item] += ing.amount; requiredMaterials[ing.item] += ing.amount;
} }
if (b.shipLayout.has_value()) if (shipLayout.has_value())
{ {
for (const PlacedModule& pm : b.shipLayout->placedModules) for (const PlacedModule& pm : 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,6 +38,12 @@ 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

@@ -158,7 +158,7 @@ BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
// Frameless: the dialog draws its own header row, so an OS title bar would only // Frameless: the dialog draws its own header row, so an OS title bar would only
// repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones -- // repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
// rounding a top-level window needs a translucent background, which is unreliable // rounding a top-level window needs a translucent background, which is unreliable
// on Windows. The border matches the build bar and the side panel. // on Windows. The border matches the build bar and the selection panel.
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint); setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
setAttribute(Qt::WA_StyledBackground, true); setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral( setStyleSheet(QStringLiteral(

View File

@@ -2,9 +2,7 @@
#include <string> #include <string>
#include <QByteArray>
#include <QColor> #include <QColor>
#include <QFile>
#include <QFont> #include <QFont>
#include <QFontMetrics> #include <QFontMetrics>
#include <QGuiApplication> #include <QGuiApplication>
@@ -15,12 +13,11 @@
#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"
@@ -54,45 +51,15 @@ 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;
// Rasterizes a chip SVG straight at its on-screen size times the device pixel // Normal and grey-background chip pixmaps for one icon name. Empty pixmaps if the
// ratio, so it stays crisp without a downscale step. // file cannot be read; the caller then falls back to a name caption
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(const QString& path) ChipPixmaps loadChipPixmaps(BuildingIconCache& icons, const std::string& iconName)
{ {
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) { return {}; }
const QByteArray svg = file.readAll();
ChipPixmaps result; ChipPixmaps result;
result.normal = renderChip(svg); result.normal = icons.getChip(iconName, kIconSize.width());
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;
} }
@@ -181,18 +148,18 @@ namespace
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config, BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
const std::string& iconDir, BuildingIconCache* buildingIcons,
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_iconDir(iconDir) , m_buildingIcons(buildingIcons)
, 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
// brings its own opaque background to stay legible over any world content // brings its own opaque background to stay legible over any world content
// (REQ-UI-BUILD-BAR). Palette colors keep it consistent with the buttons it holds // (REQ-UI-BUILD-BAR). Palette colors keep it consistent with the buttons it holds
// and with the side panels; this is widget chrome, not world rendering, so it is // and with the selection panel; this is widget chrome, not world rendering, so it is
// deliberately not a visuals.toml color. // deliberately not a visuals.toml color.
setAttribute(Qt::WA_StyledBackground, true); setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral( setStyleSheet(QStringLiteral(
@@ -231,13 +198,12 @@ 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(iconPath), InputMapper::getBuildHotkeyLabel(def.type), name, loadChipPixmaps(*m_buildingIcons, def.id),
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);
@@ -268,7 +234,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(QString::fromStdString(m_iconDir) + "/deconstruct.svg"), loadChipPixmaps(*m_buildingIcons, "deconstruct"),
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(), QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
font(), palette()); font(), palette());
@@ -310,6 +276,11 @@ void BuildButtonBar::anchorTo(const QRect& worldViewRect)
recenter(); recenter();
} }
int BuildButtonBar::getStripHeightPx() const
{
return height() + kBottomMarginPx;
}
void BuildButtonBar::clearActiveButton() void BuildButtonBar::clearActiveButton()
{ {
if (m_activeIndex) if (m_activeIndex)

View File

@@ -20,6 +20,7 @@
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
@@ -36,13 +37,11 @@ class BuildButtonBar : public QWidget,
Q_OBJECT Q_OBJECT
public: public:
// iconDir is the directory holding the per-building "<id>.svg" chip icons // buildingIcons supplies each button's chip icon (REQ-UI-BUILD-ICON) and itemIcons
// (REQ-UI-BUILD-ICON), read from disk at runtime like the config files. // the building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Both are
// itemIcons is the window-wide per-item icon cache and supplies the // window-wide caches; neither is owned, and both must outlive this widget.
// 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,
const std::string& iconDir, ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, ItemIconCache* itemIcons,
QWidget* parent = nullptr); QWidget* parent = nullptr);
~BuildButtonBar() override; ~BuildButtonBar() override;
@@ -51,6 +50,11 @@ public:
// re-center later driven by an unlock needs no second call from the owner. // re-center later driven by an unlock needs no second call from the owner.
void anchorTo(const QRect& worldViewRect); void anchorTo(const QRect& worldViewRect);
// 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();
private: private:
@@ -80,8 +84,8 @@ private slots:
private: private:
Simulation* m_sim; Simulation* m_sim;
const GameConfig* m_config; const GameConfig* m_config;
std::string m_iconDir; BuildingIconCache* m_buildingIcons; // Not owned; lives in MainWindow.
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;

View File

@@ -0,0 +1,120 @@
#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

@@ -0,0 +1,55 @@
#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,3 +1,5 @@
add_subdirectory(selection)
SET(HDRS SET(HDRS
${HDRS} ${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/VisualsConfig.h
@@ -11,8 +13,7 @@ SET(HDRS
${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}/BuildButtonBar.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.h
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.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
@@ -22,10 +23,16 @@ 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
@@ -37,8 +44,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
${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}/SelectedBuildingPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SelectionPanel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.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
@@ -48,6 +54,7 @@ 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,403 +0,0 @@
#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 SelectedBuildingPanel 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

@@ -1,91 +0,0 @@
#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 side panel is arbitrated by the parent
// SelectedBuildingPanel: 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 side 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

@@ -678,7 +678,7 @@ void GameWorldView::transferConfigTo(BuildingId id, const BlueprintBuilding& sou
if (source.type == BuildingType::Splitter) if (source.type == BuildingType::Splitter)
{ {
// Operational splitters are configured by tile, sites by BuildingId (mirrors // Operational splitters are configured by tile, sites by BuildingId (mirrors
// SelectedBuildingPanel::onSplitterFilterChanged). Locked item types are dropped // SelectionPanel::onSplitterFilterChanged). Locked item types are dropped
// per REQ-LOCK-UI-BLUEPRINT. // per REQ-LOCK-UI-BLUEPRINT.
const std::vector<ItemType> filterA = filterUnlockedItems(source.splitterFilterA, *m_sim); const std::vector<ItemType> filterA = filterUnlockedItems(source.splitterFilterA, *m_sim);
const std::vector<ItemType> filterB = filterUnlockedItems(source.splitterFilterB, *m_sim); const std::vector<ItemType> filterB = filterUnlockedItems(source.splitterFilterB, *m_sim);
@@ -1582,7 +1582,7 @@ void GameWorldView::handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEv
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event) void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
{ {
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this // Other widgets (MainWindow, SelectionPanel) request commands via this
// event; GameWorldView owns the CommandManager and enqueues them. // event; GameWorldView owns the CommandManager and enqueues them.
if (event->command && event->command->kind == CommandKind::Reset) if (event->command && event->command->kind == CommandKind::Reset)
{ {

View File

@@ -28,9 +28,10 @@
#include "RecipeSelectionDialog.h" #include "RecipeSelectionDialog.h"
#include "SchematicChoiceDialog.h" #include "SchematicChoiceDialog.h"
#include "HeaderBar.h" #include "HeaderBar.h"
#include "SelectedBuildingPanel.h" #include "SelectionPanel.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"
@@ -48,31 +49,28 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
setWindowTitle(tr("Dota Factory")); setWindowTitle(tr("Dota Factory"));
resize(1280, 768); resize(1280, 768);
// Item icons live alongside the config (a sibling of the config dir), read from // Item and building icons live alongside the config (siblings of the config dir),
// disk at runtime like the building icons and visuals.toml (REQ-UI-ITEM-ICON). // read from disk at runtime the same way visuals.toml is (REQ-UI-ITEM-ICON,
const std::string itemsIconDir = QDir::cleanPath( // REQ-UI-BUILD-ICON).
QString::fromStdString(m_configDir) + "/../icons/items").toStdString(); const QString configDirPath = QString::fromStdString(m_configDir);
m_itemIcons = std::make_unique<ItemIconCache>( m_itemIcons = std::make_unique<ItemIconCache>(
QString::fromStdString(itemsIconDir)); QDir::cleanPath(configDirPath + "/../icons/items"));
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 // Floats over the game world at its bottom center, sized to its buttons
// 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 rather than living in the side panel column
// (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(), iconDir, m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(),
m_itemIcons.get(), this); m_buildingIcons.get(), m_itemIcons.get(),
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
@@ -80,23 +78,14 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
// 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);
m_sidePanel = new QWidget(this); // Floats over the game world at its right edge rather than occupying a column of
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); // its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like
sideLayout->setContentsMargins(1, 1, 1, 1); // the build button bar it is a sibling of the world view built after it, which is
sideLayout->setSpacing(1); // what puts it above the world and its vignettes and below the dim overlay. It
// brings its own chrome; its geometry comes from layoutPanels().
// The selected building panel is the column's only panel and fills its height m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), &m_visuals,
// (REQ-UI-PANEL-COLUMN). m_itemIcons.get(), m_buildingIcons.get(),
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel); this);
sideLayout->addWidget(m_selectedBuildingPanel, 1);
// Draw a thin border around the side panel section. The class scoped selector keeps
// the border on the panel itself rather than cascading onto its child widgets;
// WA_StyledBackground lets the plain QWidget subclass honor the stylesheet box
// (border/background).
m_selectedBuildingPanel->setAttribute(Qt::WA_StyledBackground, true);
m_sidePanel->setStyleSheet(QStringLiteral(
"SelectedBuildingPanel { border: 1px solid palette(mid); }"));
// 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).
@@ -166,15 +155,20 @@ void MainWindow::layoutPanels()
const int totalH = height(); const int totalH = height();
const int headerH = m_headerBar->sizeHint().height(); const int headerH = m_headerBar->sizeHint().height();
if (headerH <= 0) { return; } if (headerH <= 0) { return; }
const int mainW = totalW * 75 / 100;
const int sideW = totalW - mainW;
m_headerBar->setGeometry(0, 0, mainW, headerH); // Header bar and game world view span the full window width; the two floating
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH); // widgets below are the only things over the world (REQ-UI-HEADER,
m_sidePanel->setGeometry(mainW, 0, sideW, totalH); // REQ-UI-WORLD-SIZE).
const QRect worldRect(0, headerH, totalW, totalH - headerH);
m_headerBar->setGeometry(0, 0, totalW, headerH);
m_gameWorldView->setGeometry(worldRect);
// Sizes itself to its buttons and centers along the bottom of the world view // Sizes itself to its buttons and centers along the bottom of the world view
// (REQ-UI-BUILD-BAR). // (REQ-UI-BUILD-BAR).
m_buildButtonBar->anchorTo(QRect(0, headerH, mainW, totalH - headerH)); 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);
} }

View File

@@ -28,9 +28,10 @@ struct ParsedReplay;
class Simulation; class Simulation;
class GameWorldView; class GameWorldView;
class HeaderBar; class HeaderBar;
class SelectedBuildingPanel; class SelectionPanel;
class BuildButtonBar; class BuildButtonBar;
class BlueprintLibrary; class BlueprintLibrary;
class BuildingIconCache;
class ItemIconCache; class ItemIconCache;
class QCloseEvent; class QCloseEvent;
class QResizeEvent; class QResizeEvent;
@@ -92,14 +93,16 @@ 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;
SelectedBuildingPanel* m_selectedBuildingPanel; SelectionPanel* m_selectionPanel;
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).
std::unique_ptr<BlueprintLibrary> m_blueprintLibrary; std::unique_ptr<BlueprintLibrary> m_blueprintLibrary;
QWidget* m_sidePanel;
ModalDimOverlay* m_dimOverlay = nullptr; ModalDimOverlay* m_dimOverlay = nullptr;
std::vector<ShipLayoutBlueprint> m_layoutBlueprints; std::vector<ShipLayoutBlueprint> m_layoutBlueprints;

View File

@@ -1,858 +0,0 @@
#include "SelectedBuildingPanel.h"
#include "FactoryQueries.h"
#include <algorithm>
#include <cctype>
#include <map>
#include <set>
#include <string>
#include <QLabel>
#include <QListWidget>
#include <QPushButton>
#include <QVBoxLayout>
#include "BeltSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventManager.h"
#include "FieldSelectionPanel.h"
#include "TickAdvancedEvent.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ItemType.h"
#include "LayoutDialogRequestedEvent.h"
#include "ModulesConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipeSelectionDialog.h"
#include "RecipeSelectionRequestedEvent.h"
#include "Rotation.h"
#include "ShipLayoutPreview.h"
#include "Simulation.h"
namespace
{
QString buildingTypeName(BuildingType type)
{
if (type == BuildingType::Hq)
{
return QObject::tr("Player HQ");
}
const std::string id = buildingTypeId(type);
QString result;
bool nextUpper = true;
for (char c : id)
{
if (c == '_')
{
result += ' ';
nextUpper = true;
}
else if (nextUpper)
{
result += static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
nextUpper = false;
}
else
{
result += c;
}
}
return result;
}
bool isProductionBuilding(BuildingType type)
{
return type == BuildingType::Miner
|| type == BuildingType::Smelter
|| type == BuildingType::Assembler
|| type == BuildingType::ReprocessingPlant
|| type == BuildingType::Shipyard;
}
// Buildings that expose a player recipe/schematic selection control
// (REQ-UI-SELECT-BUTTON): Miner ore type, Assembler recipe, Shipyard schematic.
// The Smelter and Reprocessing Plant auto-process and offer no selection
// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING).
bool hasRecipeSelection(BuildingType type)
{
return type == BuildingType::Miner
|| type == BuildingType::Assembler
|| type == BuildingType::Shipyard;
}
QString rotationLabel(Rotation r)
{
switch (r)
{
case Rotation::North: return QObject::tr("North (↑)");
case Rotation::East: return QObject::tr("East (→)");
case Rotation::South: return QObject::tr("South (↓)");
case Rotation::West: return QObject::tr("West (←)");
}
return "";
}
} // namespace
SelectedBuildingPanel::SelectedBuildingPanel(Simulation* sim,
const GameConfig* config,
QWidget* parent)
: QWidget(parent)
, m_sim(sim)
, m_config(config)
, m_splitterTile(0, 0)
{
m_layout = new QVBoxLayout(this);
m_layout->setContentsMargins(8, 8, 8, 8);
m_layout->setSpacing(4);
m_layout->setAlignment(Qt::AlignTop);
m_titleLabel = new QLabel(this);
m_recipeSelectButton = new QPushButton(this);
m_clearBeltBtn = new QPushButton(tr("Clear Items"), this);
m_filterALabel = new QLabel(this);
m_filterAList = new QListWidget(this);
m_filterBLabel = new QLabel(this);
m_filterBList = new QListWidget(this);
m_layoutPreview = new ShipLayoutPreview(this);
m_configureLayoutBtn = new QPushButton(tr("Configure Layout"), this);
m_buffersLabel = new QLabel(this);
m_buffersLabel->setWordWrap(true);
m_filterAList->setMaximumHeight(100);
m_filterBList->setMaximumHeight(100);
m_layout->addWidget(m_titleLabel);
m_layout->addWidget(m_recipeSelectButton);
m_layout->addWidget(m_layoutPreview);
m_layout->addWidget(m_configureLayoutBtn);
m_layout->addWidget(m_clearBeltBtn);
m_layout->addWidget(m_filterALabel);
m_layout->addWidget(m_filterAList);
m_layout->addWidget(m_filterBLabel);
m_layout->addWidget(m_filterBList);
m_layout->addWidget(m_buffersLabel);
connect(m_recipeSelectButton, &QPushButton::clicked,
this, &SelectedBuildingPanel::onSelectRecipeClicked);
connect(m_clearBeltBtn, &QPushButton::clicked,
this, &SelectedBuildingPanel::onClearBelt);
connect(m_configureLayoutBtn, &QPushButton::clicked, this, [this]() {
if (m_singleBuildingId.has_value())
{
EventManager::getInstance()->sendEventImmediately(
std::make_shared<LayoutDialogRequestedEvent>(*m_singleBuildingId));
}
});
connect(m_filterAList, &QListWidget::itemChanged,
this, &SelectedBuildingPanel::onSplitterFilterChanged);
connect(m_filterBList, &QListWidget::itemChanged,
this, &SelectedBuildingPanel::onSplitterFilterChanged);
// The field selection renders below the building content and hides itself while
// nothing field-side is selected, so it costs no space then.
m_fieldSelectionPanel = new FieldSelectionPanel(sim, config, this);
m_layout->addWidget(m_fieldSelectionPanel);
buildEmpty();
registerForEvents();
}
SelectedBuildingPanel::~SelectedBuildingPanel()
{
unregisterForEvents();
}
void SelectedBuildingPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
{
m_selectedBuildingIds = ids;
if (!ids.empty())
{
// A building selection is exclusive: it supersedes any field selection —
// actors and scrap (REQ-UI-SELECTION-CATEGORIES).
m_fieldSelectionPanel->clearSelection();
}
rebuild();
}
void SelectedBuildingPanel::yieldToFieldSelection()
{
// The mirror image of onSelectionChanged(): a field selection — actors, debris, or
// both — supersedes any building selection (REQ-UI-SELECTION-CATEGORIES). An empty
// field selection changes nothing here: the building content, if any, keeps the panel.
if (!m_fieldSelectionPanel->hasSelection()) { return; }
m_selectedBuildingIds.clear();
buildEmpty();
}
void SelectedBuildingPanel::rebuild()
{
if (m_selectedBuildingIds.empty())
{
buildEmpty();
}
else if (m_selectedBuildingIds.size() == 1)
{
buildSingle(m_selectedBuildingIds[0]);
}
else
{
buildMulti(m_selectedBuildingIds);
}
}
void SelectedBuildingPanel::hideAllWidgets()
{
m_titleLabel->hide();
m_recipeSelectButton->hide();
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
m_clearBeltBtn->hide();
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
m_buffersLabel->hide();
}
void SelectedBuildingPanel::buildEmpty()
{
// Shows nothing for the building category — either because nothing is selected or
// because the field category has taken the panel over.
m_singleBuildingId = std::nullopt;
hideAllWidgets();
}
void SelectedBuildingPanel::buildSingle(BuildingId id)
{
m_singleBuildingId = id;
hideAllWidgets();
const Building* b = findBuilding(m_sim->getFactoryState(), id);
const ConstructionSite* s = b ? nullptr : findSite(m_sim->getFactoryState(), id);
if (!b && !s)
{
buildEmpty();
return;
}
m_singleIsSite = (s != nullptr);
// A construction site exposes the same configuration as the operational
// building it will become (REQ-BLD-SITE-CONFIG). The only difference is
// that its buffer/production rows are replaced by a construction-progress
// line, since a site has no buffers and runs no production cycle.
const BuildingType type = b ? b->type : s->type;
const std::string& recipeId = b ? b->recipeId : s->recipeId;
const std::optional<ShipLayoutConfig>& shipLayout =
b ? b->shipLayout : s->shipLayout;
const QPoint anchor = b ? b->anchor : s->anchor;
m_titleLabel->setText(m_singleIsSite
? tr("(Building) %1").arg(buildingTypeName(type))
: buildingTypeName(type));
m_titleLabel->show();
m_buffersLabel->show();
if (hasRecipeSelection(type))
{
const std::vector<RecipeSelectionOption> options =
buildRecipeSelectionOptions(type, *m_sim, *m_config);
const RecipeSelectionOption* current = nullptr;
for (const RecipeSelectionOption& option : options)
{
if (option.id == recipeId)
{
current = &option;
break;
}
}
if (current && !current->id.empty())
{
m_recipeSelectButton->setText(current->caption);
m_recipeSelectButton->setToolTip(current->tooltip);
}
else
{
const QString placeholder = (type == BuildingType::Shipyard)
? tr("Select schematic")
: tr("Select recipe");
m_recipeSelectButton->setText(placeholder);
m_recipeSelectButton->setToolTip(QString());
}
m_recipeSelectButton->show();
updateShipyardLayoutWidgets(type, recipeId, shipLayout);
}
else
{
m_recipeSelectButton->hide();
updateShipyardLayoutWidgets(type, recipeId, shipLayout);
}
// Belt "Clear" removes items from a live belt tile; a construction site has
// none and is not registered with BeltSystem yet, so hide it for sites.
if (isBeltSubsystemType(type) && !m_singleIsSite)
{
m_clearBeltBtn->show();
}
else
{
m_clearBeltBtn->hide();
}
if (type == BuildingType::Splitter)
{
std::optional<BeltSystem::SplitterInfo> info;
if (m_singleIsSite)
{
info = getSiteSplitterInfo(m_sim->getFactoryState(), m_sim->getConfig(), id);
}
else
{
m_splitterTile = anchor;
info = m_sim->getBelts().getSplitterInfo(m_splitterTile);
}
buildSplitterFilters(info);
}
else
{
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
}
if (m_singleIsSite)
{
refreshSiteProgress(s);
}
else
{
refreshBuffers(b);
}
}
void SelectedBuildingPanel::refreshSiteProgress(const ConstructionSite* s)
{
QString progress;
if (s->completesAt == 0)
{
progress = tr("Queued");
}
else
{
const BuildingDef* def = nullptr;
for (const BuildingDef& d : m_config->buildings.buildings)
{
if (d.type == s->type) { def = &d; break; }
}
if (def && def->constructionTimeSeconds > 0)
{
const Tick duration = secondsToTicks(def->constructionTimeSeconds);
const Tick elapsed = m_sim->getCurrentTick() - (s->completesAt - duration);
const int pct = static_cast<int>(
std::max(Tick(0), std::min(duration, elapsed)) * 100 / duration);
progress = tr("%1% complete").arg(pct);
}
else
{
progress = tr("Building...");
}
}
m_buffersLabel->setText(progress);
}
void SelectedBuildingPanel::refreshBuffers(const Building* b)
{
const RecipeDef* recipe = findRecipe(b);
const ShipDef* shipDef = (b->type == BuildingType::Shipyard)
? findShipDef(b->recipeId)
: nullptr;
// Auto-recipe buildings (Smelter, Reprocessing Plant) have no selected
// recipe; while a cycle runs, resolve the recipe actually in production so
// the cycle time and progress can be shown (REQ-UI-PRODUCTION-PROGRESS).
if (!recipe && isAutoRecipeBuildingType(b->type) && b->production.has_value())
{
recipe = m_config->recipes.findRecipeDef(b->production->recipeId, b->type);
}
QString bufText;
if (!b->inputBuffer.counts.empty())
{
bufText += tr("Input: ");
for (const std::pair<const ItemType, int>& entry : b->inputBuffer.counts)
{
int perCycle = 0;
if (recipe)
{
for (const RecipeIngredient& ing : recipe->inputs)
{
if (ing.item == entry.first.id) { perCycle = ing.amount; break; }
}
}
else if (shipDef)
{
for (const RecipeIngredient& mat : shipDef->schematic.materials)
{
if (mat.item == entry.first.id) { perCycle = mat.amount; break; }
}
if (b->shipLayout.has_value())
{
for (const PlacedModule& pm : b->shipLayout->placedModules)
{
const ModuleDef* modDef =
m_config->modules.findModuleDef(pm.moduleId);
if (!modDef) { continue; }
for (const RecipeIngredient& ing : modDef->materials)
{
if (ing.item == entry.first.id)
{
perCycle += ing.amount;
}
}
}
}
}
bufText += QString::fromStdString(entry.first.id)
+ ": " + QString::number(entry.second);
if (perCycle > 0)
{
bufText += "/" + QString::number(perCycle);
}
bufText += " ";
}
bufText += "\n";
}
// Count output-side items: buffered plus still-emerging on the output belts.
// An emerging item still belongs to the output buffer (REQ-MAT-OUTPUT-EMERGE),
// so it must be included here or it would vanish from the panel while animating.
std::map<std::string, int> outCounts;
for (const Item& item : b->outputBuffer.items)
{
outCounts[item.type.id]++;
}
for (const std::vector<BeltItemSlot>& lane : b->emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
outCounts[slot.item.type.id]++;
}
}
if (recipe && !recipe->outputs.empty())
{
bufText += tr("Output: ");
for (const RecipeOutput& out : recipe->outputs)
{
const std::map<std::string, int>::const_iterator it =
outCounts.find(out.item);
const int count = (it != outCounts.end()) ? it->second : 0;
bufText += QString::fromStdString(out.item)
+ ": " + QString::number(count)
+ "/" + QString::number(out.amount) + " ";
}
}
else if (!outCounts.empty())
{
bufText += tr("Output: ");
for (const std::pair<const std::string, int>& entry : outCounts)
{
bufText += QString::fromStdString(entry.first)
+ ": " + QString::number(entry.second) + " ";
}
}
if (isProductionBuilding(b->type)
&& (recipe || shipDef || isAutoRecipeBuildingType(b->type)))
{
if (recipe || shipDef)
{
double durationSeconds = recipe
? recipe->durationSeconds
: shipDef->schematic.productionTimeSeconds;
if (shipDef && b->shipLayout.has_value())
{
for (const PlacedModule& pm : b->shipLayout->placedModules)
{
const ModuleDef* modDef =
m_config->modules.findModuleDef(pm.moduleId);
if (modDef)
{
durationSeconds += modDef->productionTimeSeconds;
}
}
}
bufText += tr("Cycle: %1 s\n").arg(durationSeconds, 0, 'f', 1);
if (b->production.has_value())
{
const Tick cycleTicks = secondsToTicks(durationSeconds);
const Tick completesAt = b->production->completesAt;
const Tick currentTick = m_sim->getCurrentTick();
const Tick elapsed = currentTick - (completesAt - cycleTicks);
const int pct = static_cast<int>(
std::max(Tick(0), std::min(cycleTicks, elapsed)) * 100 / cycleTicks);
bufText += tr("Progress: %1%\n").arg(pct);
}
else
{
bufText += tr("Progress: idle\n");
}
}
else
{
// Auto-recipe building with no active cycle: no single recipe to
// show a cycle time for.
bufText += tr("Progress: idle\n");
}
}
m_buffersLabel->setText(bufText);
// The recipe/schematic is applied via a queued command that only drains on a
// later frame, so the per-tick refresh must own the shipyard preview and the
// Configure Layout button's visibility; otherwise they stay hidden until the
// building is re-selected (which re-runs buildSingle).
updateShipyardLayoutWidgets(b->type, b->recipeId, b->shipLayout);
}
void SelectedBuildingPanel::updateShipyardLayoutWidgets(
BuildingType type,
const std::string& recipeId,
const std::optional<ShipLayoutConfig>& shipLayout)
{
// The preview and Configure button are shipyard-only controls; hide them
// entirely for other building types.
if (type != BuildingType::Shipyard)
{
m_layoutPreview->hide();
m_configureLayoutBtn->hide();
return;
}
const ShipDef* shipDef = findShipDef(recipeId);
const bool hasSchematic = shipDef && !shipDef->layout.empty();
// Always show the preview and Configure button for a shipyard; they are only
// enabled once a schematic is selected (REQ-MOD-UI-PREVIEW).
if (hasSchematic)
{
ShipLayoutConfig layout;
if (shipLayout.has_value())
{
layout = *shipLayout;
}
m_layoutPreview->setShipAndLayout(
shipDef->layout, layout, &m_config->modules);
}
else
{
m_layoutPreview->showPlaceholder();
}
m_layoutPreview->setEnabled(hasSchematic);
m_configureLayoutBtn->setEnabled(hasSchematic);
m_layoutPreview->show();
m_configureLayoutBtn->show();
}
const RecipeDef* SelectedBuildingPanel::findRecipe(const Building* b) const
{
if (b->recipeId.empty()) { return nullptr; }
return m_config->recipes.findRecipeDef(b->recipeId, b->type);
}
const ShipDef* SelectedBuildingPanel::findShipDef(const std::string& id) const
{
if (id.empty()) { return nullptr; }
return m_config->ships.findShipDef(id);
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{
refreshSelectionDisplay(RefreshReason::PeriodicTick);
}
void SelectedBuildingPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{
// Player commands (e.g. choosing a shipyard schematic) are applied by a
// queued drain, not synchronously. When the game is paused no tick advances,
// so TickAdvancedEvent never fires; refresh here too, otherwise the panel
// would not reflect the change until the next tick or a re-selection.
refreshSelectionDisplay(RefreshReason::CommandApplied);
}
void SelectedBuildingPanel::refreshSelectionDisplay(RefreshReason reason)
{
// Only a single selected building has live content to refresh. While the field
// category owns the panel there is none: yieldToFieldSelection() has cleared it, so
// this returns immediately and the field panel refreshes itself off the same events.
if (!m_singleBuildingId.has_value()) { return; }
const Building* b = findBuilding(m_sim->getFactoryState(), *m_singleBuildingId);
if (b)
{
if (m_titleLabel->text().startsWith(tr("(Building) ")))
{
rebuild();
}
else
{
refreshBuffers(b);
}
return;
}
const ConstructionSite* s = findSite(m_sim->getFactoryState(), *m_singleBuildingId);
if (s)
{
// A periodic tick only advances construction progress, so update just the
// progress label. Rebuilding every tick would hide/re-show all widgets and
// cancel any in-progress click on the recipe button. An applied command
// may have changed the site's recipe/layout, so rebuild in that case.
if (reason == RefreshReason::CommandApplied)
{
rebuild();
}
else
{
refreshSiteProgress(s);
}
return;
}
buildEmpty();
}
void SelectedBuildingPanel::buildMulti(const std::vector<BuildingId>& ids)
{
m_singleBuildingId = std::nullopt;
m_recipeSelectButton->hide();
m_clearBeltBtn->hide();
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
m_buffersLabel->hide();
std::map<BuildingType, int> counts;
for (BuildingId id : ids)
{
const Building* b = findBuilding(m_sim->getFactoryState(), id);
if (b)
{
counts[b->type]++;
continue;
}
const ConstructionSite* s = findSite(m_sim->getFactoryState(), id);
if (s)
{
counts[s->type]++;
}
}
bool hasBelt = false;
int totalCost = 0;
QString text;
for (const std::pair<const BuildingType, int>& entry : counts)
{
text += buildingTypeName(entry.first) + " x "
+ QString::number(entry.second) + "\n";
if (isBeltSubsystemType(entry.first))
{
hasBelt = true;
}
// Total placement cost counts only player-placeable buildings; the HQ
// and defence stations are excluded (REQ-UI-MULTI-SELECTION).
const BuildingDef* def = m_config->buildings.findBuildingDef(entry.first);
if (def && def->playerPlaceable)
{
totalCost += def->cost * entry.second;
}
}
text += tr("Total: %1 Building Blocks").arg(totalCost);
m_titleLabel->setText(text.trimmed());
m_titleLabel->show();
if (hasBelt)
{
m_clearBeltBtn->show();
}
}
void SelectedBuildingPanel::onSelectRecipeClicked()
{
if (!m_singleBuildingId.has_value())
{
return;
}
// The emit is synchronous: MainWindow pauses the game, runs the modal
// selection dialog, and restores the speed before this returns. The chosen
// recipe/schematic is only *enqueued* as a command, though, and drains on a
// later frame -- so this rebuild() still sees the old recipe. The per-tick
// refreshBuffers() path picks up the new schematic (and shows the layout
// preview + Configure Layout button) once the command has been applied.
EventManager::getInstance()->sendEventImmediately(
std::make_shared<RecipeSelectionRequestedEvent>(*m_singleBuildingId));
rebuild();
}
void SelectedBuildingPanel::buildSplitterFilters(
const std::optional<BeltSystem::SplitterInfo>& info)
{
if (!info.has_value())
{
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
return;
}
const std::vector<std::string> items = getAllItemIds();
auto populateList = [&](QListWidget* list, QLabel* label,
const QString& dirLabel,
const std::vector<ItemType>& filter)
{
label->setText(tr("%1 filter (empty = all):").arg(dirLabel));
list->blockSignals(true);
list->clear();
for (const std::string& itemId : items)
{
if (!m_sim->isItemUnlocked(itemId)) { continue; }
QListWidgetItem* row = new QListWidgetItem(
QString::fromStdString(itemId), list);
const bool checked = filter.empty()
? false
: std::find(filter.begin(), filter.end(),
ItemType{itemId}) != filter.end();
row->setCheckState(checked ? Qt::Checked : Qt::Unchecked);
row->setFlags(row->flags() | Qt::ItemIsUserCheckable);
}
list->blockSignals(false);
label->show();
list->show();
};
populateList(m_filterAList, m_filterALabel,
rotationLabel(info->outputA), info->filterA);
populateList(m_filterBList, m_filterBLabel,
rotationLabel(info->outputB), info->filterB);
}
void SelectedBuildingPanel::onSplitterFilterChanged()
{
if (!m_singleBuildingId.has_value())
{
return;
}
auto collectFilter = [](QListWidget* list) -> std::vector<ItemType>
{
std::vector<ItemType> filter;
for (int i = 0; i < list->count(); ++i)
{
const QListWidgetItem* row = list->item(i);
if (row->checkState() == Qt::Checked)
{
filter.push_back(ItemType{row->text().toStdString()});
}
}
return filter;
};
if (m_singleIsSite)
{
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = *m_singleBuildingId;
command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{
std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>();
command->tile = m_splitterTile;
command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
}
std::vector<std::string> SelectedBuildingPanel::getAllItemIds() const
{
std::set<std::string> seen;
for (const RecipeDef& recipe : m_config->recipes.recipes)
{
for (const RecipeIngredient& ing : recipe.inputs)
{
seen.insert(ing.item);
}
for (const RecipeOutput& out : recipe.outputs)
{
seen.insert(out.item);
}
}
return std::vector<std::string>(seen.begin(), seen.end());
}
void SelectedBuildingPanel::onClearBelt()
{
std::vector<QPoint> tiles;
for (BuildingId id : m_selectedBuildingIds)
{
const Building* b = findBuilding(m_sim->getFactoryState(), id);
if (b && isBeltSubsystemType(b->type))
{
for (const QPoint& cell : b->bodyCells)
{
tiles.push_back(cell);
}
}
}
if (!tiles.empty())
{
std::shared_ptr<ClearBeltTilesCommand> command =
std::make_shared<ClearBeltTilesCommand>();
command->tiles = std::move(tiles);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> event)
{
m_fieldSelectionPanel->setSelectedEntities(event->entities);
yieldToFieldSelection();
}
void SelectedBuildingPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
onSelectionChanged(event->ids);
}
void SelectedBuildingPanel::handleEvent(
std::shared_ptr<const DebrisSelectionChangedEvent> event)
{
// Debris is a field object: it supersedes any building selection but coexists
// with actors (REQ-UI-SELECTION-CATEGORIES).
m_fieldSelectionPanel->setSelectedDebris(event->debris);
yieldToFieldSelection();
}

View File

@@ -1,122 +0,0 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
#include <QPoint>
#include <QWidget>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingId.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "GameConfig.h"
#include "PlayerCommandsAppliedEvent.h"
#include "RecipesConfig.h"
#include "DebrisSelectionChangedEvent.h"
#include "SelectionChangedEvent.h"
#include "ShipLayout.h"
#include "ShipsConfig.h"
#include "Tick.h"
#include "TickAdvancedEvent.h"
class Simulation;
class FieldSelectionPanel;
class ShipLayoutPreview;
class QLabel;
class QListWidget;
class QPushButton;
class QVBoxLayout;
// Shows the current selection. The building category (buildings and construction sites)
// is rendered by this panel itself; the field category (ships, defence stations, debris)
// is rendered by the embedded FieldSelectionPanel.
//
// 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.
class SelectedBuildingPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectionChangedEvent,
SelectionChangedEvent,
DebrisSelectionChangedEvent>
{
Q_OBJECT
public:
SelectedBuildingPanel(Simulation* sim, const GameConfig* config,
QWidget* parent = nullptr);
~SelectedBuildingPanel() override;
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 EntitySelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> event) override;
private slots:
void onSelectRecipeClicked();
void onClearBelt();
void onSplitterFilterChanged();
private:
// Why the selection display is being refreshed. A periodic tick only needs a
// lightweight content update (e.g. a construction site's progress label),
// whereas an applied player command may have changed the configuration and
// needs a full structural rebuild.
enum class RefreshReason
{
PeriodicTick,
CommandApplied
};
void onSelectionChanged(const std::vector<BuildingId>& ids);
// Gives the panel to the field category once it has anything selected.
void yieldToFieldSelection();
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;
Simulation* m_sim;
const GameConfig* m_config;
std::vector<BuildingId> m_selectedBuildingIds;
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;
};

267
src/ui/SelectionPanel.cpp Normal file
View File

@@ -0,0 +1,267 @@
#include "SelectionPanel.h"
#include <QScrollArea>
#include <QScrollBar>
#include <QVBoxLayout>
#include "BuildingIconCache.h"
#include "ItemIconCache.h"
#include "Simulation.h"
#include "VisualsConfig.h"
#include "selection/SelectionContent.h"
namespace
{
// Distance kept between the panel and the edges of the band it is anchored to
// (REQ-UI-SELECTION-PANEL).
const int kMarginPx = 8;
// Upper bound on the card width. The panel is content-sized, but several of the cards'
// widgets have no natural width of their own -- the word-wrapped summary labels grow
// without limit, and a QListWidget asks for 256 px whatever it holds -- so the width is
// capped and the labels wrap at the cap. 320 px is the width the former side panel
// column had at the default window size.
const int kMaxContentWidthPx = 320;
// Padding between the panel's border and the card inside it.
const int kCardMarginPx = 8;
} // namespace
SelectionPanel::SelectionPanel(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, ItemIconCache* itemIcons,
BuildingIconCache* buildingIcons, QWidget* parent)
: QWidget(parent)
{
m_context.sim = sim;
m_context.config = config;
m_context.visuals = visuals;
m_context.itemIcons = itemIcons;
m_context.buildingIcons = buildingIcons;
m_context.debugDrawEnabled = &m_debugDrawEnabled;
// The panel floats over the rendered world rather than sitting in a column, so it
// brings its own opaque background to stay legible over any world content
// (REQ-UI-SELECTION-PANEL). Palette colors match the build button bar's chrome; like
// it, 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 itself
// rather than cascading onto its child widgets.
setAttribute(Qt::WA_StyledBackground, true);
setStyleSheet(QStringLiteral(
"SelectionPanel { background-color: palette(window);"
" border: 1px solid palette(mid); border-radius: 4px; }"));
// A card taller than the band scrolls rather than overrunning it
// (REQ-UI-SELECTION-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_body = new QWidget(this);
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_body->setAutoFillBackground(false);
m_scrollArea->setWidget(m_body);
QVBoxLayout* outerLayout = new QVBoxLayout(this);
outerLayout->setContentsMargins(0, 0, 0, 0);
outerLayout->setSpacing(0);
outerLayout->addWidget(m_scrollArea);
m_bodyLayout = new QVBoxLayout(m_body);
m_bodyLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
kCardMarginPx, kCardMarginPx);
m_bodyLayout->setSpacing(0);
m_bodyLayout->setAlignment(Qt::AlignTop);
hide();
registerForEvents();
}
SelectionPanel::~SelectionPanel()
{
unregisterForEvents();
}
void SelectionPanel::anchorTo(const QRect& bandRect)
{
m_bandRect = bandRect;
updateVisibility();
}
void SelectionPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
{
m_request.buildings = event->ids;
if (!m_request.buildings.empty())
{
// A building selection is exclusive: it supersedes any field selection -- actors
// and scrap alike (REQ-UI-SELECTION-CATEGORIES).
m_request.actors.clear();
m_request.debris.clear();
}
rebuildContent();
}
void SelectionPanel::handleEvent(
std::shared_ptr<const EntitySelectionChangedEvent> event)
{
m_request.actors = event->entities;
if (!m_request.actors.empty() || !m_request.debris.empty())
{
m_request.buildings.clear();
}
rebuildContent();
}
void SelectionPanel::handleEvent(
std::shared_ptr<const DebrisSelectionChangedEvent> event)
{
// Debris is a field object: it supersedes any building selection but coexists with
// actors (REQ-UI-SELECTION-CATEGORIES).
m_request.debris = event->debris;
if (!m_request.actors.empty() || !m_request.debris.empty())
{
m_request.buildings.clear();
}
rebuildContent();
}
void SelectionPanel::handleEvent(std::shared_ptr<const TickAdvancedEvent> /*event*/)
{
refreshContent();
}
void SelectionPanel::handleEvent(
std::shared_ptr<const PlayerCommandsAppliedEvent> /*event*/)
{
// Player commands (choosing a shipyard schematic, say) are applied by a queued
// drain, not synchronously. When the game is paused no tick advances, so
// TickAdvancedEvent never fires; refreshing here too is what makes the change show
// up without waiting for a tick or a re-selection.
refreshContent();
}
void SelectionPanel::handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event)
{
m_debugDrawEnabled = event->active;
}
void SelectionPanel::refreshContent()
{
if (!m_content)
{
return;
}
// A card never changes its own shape, so when the selection now calls for a
// different one it is replaced rather than refreshed. Only a single selected
// building can reach that state without the selection itself changing -- its
// construction site finishes, or it is deconstructed under the panel. Everything
// else is re-published as a selection change, so re-deriving the key here would walk
// a large multi-selection every tick to learn nothing.
if (m_request.buildings.size() == 1
&& chooseContent(m_request, *m_context.sim) != m_contentKey)
{
rebuildContent();
return;
}
m_content->refresh();
updateVisibility();
}
void SelectionPanel::rebuildContent()
{
if (m_content)
{
// Retired rather than deleted: a rebuild can be reached from inside one of the
// card's own click handlers -- the recipe control opens a modal dialog and the
// choice comes back as a command -- and control has to be able to return into
// the widget that is going away.
m_content->hide();
m_content->deleteLater();
m_content = nullptr;
}
m_contentKey = chooseContent(m_request, *m_context.sim);
m_content = createContent(m_contentKey, m_request, m_context, m_body);
if (m_content)
{
m_bodyLayout->addWidget(m_content);
m_content->refresh();
}
updateVisibility();
}
void SelectionPanel::updateVisibility()
{
// Nothing selected in either category means no panel at all rather than an empty one
// (REQ-UI-EMPTY-SELECTION), leaving the whole game world view visible. Before the
// owner has anchored the panel there is nowhere to put it either, so it stays hidden
// until then.
const bool shouldShow = (m_content != nullptr) && !m_bandRect.isNull();
setVisible(shouldShow);
if (shouldShow)
{
refit();
}
}
void SelectionPanel::refit()
{
// The layout drops hidden widgets from its size hint, but only once it has been
// re-run: a card hides and shows its parts as it refreshes, before Qt would get
// around to it on its own.
m_body->layout()->activate();
const QRect band =
m_bandRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
// The panel's border is drawn around the scroll area rather than around the card, so
// it is added to whatever the card asks for. Spelled out here instead of read back
// from contentsMargins() because the stylesheet box is what sets it, and asking the
// style for it before the first show is unreliable.
const int borderPx = 1;
const int maxWidthPx = qMin(kMaxContentWidthPx, band.width() - 2 * borderPx);
const int maxHeightPx = band.height() - 2 * borderPx;
if (maxWidthPx <= 0 || maxHeightPx <= 0)
{
return;
}
int contentWidthPx = qMin(m_body->sizeHint().width(), maxWidthPx);
// Word-wrapped labels only know their height once the width is fixed; the layout
// reports -1 when nothing in it wraps, in which case the plain hint is exact.
int contentHeightPx = m_body->heightForWidth(contentWidthPx);
if (contentHeightPx < 0)
{
contentHeightPx = m_body->sizeHint().height();
}
if (contentHeightPx > maxHeightPx)
{
// A card taller than the band is capped there and scrolls
// (REQ-UI-SELECTION-PANEL). The scroll bar is laid out beside the card, so the
// panel widens by its width to keep the card as wide as the height was computed
// for.
contentHeightPx = maxHeightPx;
contentWidthPx = qMin(
contentWidthPx + m_scrollArea->verticalScrollBar()->sizeHint().width(),
maxWidthPx);
}
const int panelWidthPx = contentWidthPx + 2 * borderPx;
const int panelHeightPx = contentHeightPx + 2 * borderPx;
// Right-aligned in the band and centered on it vertically. The band excludes the
// build button bar's strip, so centering here never puts the panel over the bar
// (REQ-UI-SELECTION-PANEL).
setGeometry(band.right() - panelWidthPx + 1,
band.top() + (band.height() - panelHeightPx) / 2,
panelWidthPx, panelHeightPx);
}

96
src/ui/SelectionPanel.h Normal file
View File

@@ -0,0 +1,96 @@
#pragma once
#include <QRect>
#include <QWidget>
#include "DebrisSelectionChangedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "EntitySelectionChangedEvent.h"
#include "EventHandler.h"
#include "PlayerCommandsAppliedEvent.h"
#include "SelectionChangedEvent.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 QScrollArea;
class QVBoxLayout;
// Shows the current selection. The panel itself renders nothing: it arbitrates between
// the two selection categories (REQ-UI-SELECTION-CATEGORIES), picks the card that fits
// what is selected (REQ-UI-SELECTION-CONTENT), and hosts exactly one of them at a time.
// What each card looks like lives in src/ui/selection/.
//
// 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, anchors to the right edge of
// the band its owner hands it, and hides itself entirely while nothing is selected
// (REQ-UI-EMPTY-SELECTION).
class SelectionPanel : public QWidget,
public CombinedEventHandler<TickAdvancedEvent,
PlayerCommandsAppliedEvent,
EntitySelectionChangedEvent,
SelectionChangedEvent,
DebrisSelectionChangedEvent,
DebugDrawToggledEvent>
{
Q_OBJECT
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,
const VisualsConfig* visuals, ItemIconCache* itemIcons,
BuildingIconCache* buildingIcons, QWidget* parent = nullptr);
~SelectionPanel() override;
// Confines the panel to the given band of the game world view: it right-aligns
// within it and centers vertically in it. The band is the world view less the strip
// the build button bar occupies, so the two never overlap and the bar never has to
// move (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR).
void anchorTo(const QRect& bandRect);
private:
void handleEvent(std::shared_ptr<const SelectionChangedEvent> 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 PlayerCommandsAppliedEvent> event) override;
void handleEvent(std::shared_ptr<const DebugDrawToggledEvent> event) override;
// Re-reads the live values of the card on screen. When the selection now calls for a
// different card -- a construction site finishing is the case that matters -- it
// rebuilds instead, because a card never changes its own shape.
void refreshContent();
// Replaces the card with the one the current selection calls for.
void rebuildContent();
// Shows the panel while a card exists and hides it otherwise
// (REQ-UI-EMPTY-SELECTION), re-fitting it to the card while it is shown.
void updateVisibility();
// Re-fits the panel to its card within the anchored band.
void refit();
SelectionContext m_context;
// Read through m_context by the cards that need it, so a toggle reaches the card on
// screen without it having to subscribe to the event itself.
bool m_debugDrawEnabled = false;
SelectionRequest m_request;
ContentKey m_contentKey;
SelectionContent* m_content = nullptr;
// The band the panel confines itself to, in the coordinates of its parent; null
// until the owner has anchored it for the first time.
QRect m_bandRect;
// Scrolls the card once it outgrows the band (REQ-UI-SELECTION-PANEL). The card is a
// child of m_body, not of the panel itself.
QScrollArea* m_scrollArea;
QWidget* m_body;
QVBoxLayout* m_bodyLayout;
};

View File

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

View File

@@ -1,18 +1,28 @@
#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"
struct GameConfig; #include "BehaviorKind.h"
class QLabel;
struct GameConfig;
class BarRow;
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
@@ -20,44 +30,47 @@ 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);
// Displays the ship's current top-priority behavior (REQ-UI-SHIP-BEHAVIOR). // Shows the ship's top-priority behavior as a row of its own. Never called by the
// 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, const QString& hpText); void applyStats(const ShipStats& stats, double hpFraction, const QString& hpText);
const GameConfig* m_config; const GameConfig* m_config;
bool m_debugDraw = false; bool m_debugDraw = false;
QLabel* m_behaviorLabel; BarRow* m_hpBar;
QLabel* m_hpLabel; StatRow* m_speedRow;
QLabel* m_speedLabel; StatRow* m_sensorRangeRow;
QLabel* m_sensorRangeLabel; StatRow* m_mainAccelRow;
QLabel* m_mainAccelLabel; StatRow* m_maneuveringAccelRow;
QLabel* m_maneuveringAccelLabel; StatRow* m_angularAccelRow;
QLabel* m_angularAccelLabel; StatRow* m_maxRotSpeedRow;
QLabel* m_maxRotSpeedLabel; StatRow* m_cargoCapacityRow;
QLabel* m_cargoCapacityLabel;
QWidget* m_weaponSection; SectionBox* m_weaponSection;
QLabel* m_weaponDpsLabel; StatRow* m_weaponDpsRow;
QLabel* m_weaponRangeLabel; StatRow* m_weaponRangeRow;
QWidget* m_salvageSection; SectionBox* m_salvageSection;
QLabel* m_salvageRateLabel; StatRow* m_salvageRateRow;
QLabel* m_salvageRangeLabel; StatRow* m_salvageRangeRow;
QWidget* m_repairSection; SectionBox* m_repairSection;
QLabel* m_repairRateLabel; StatRow* m_repairRateRow;
QLabel* m_repairRangeLabel; StatRow* m_repairRangeRow;
QLabel* m_threatCostLabel; StatRow* m_behaviorRow;
StatRow* m_threatCostRow;
}; };

View File

@@ -0,0 +1,44 @@
#include "AutoProductionContent.h"
#include "Building.h"
#include "BuildingTarget.h"
#include "GameConfig.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 || !target.building->production.has_value())
{
return info;
}
const RecipeDef* recipe = getContext().config->recipes.findRecipeDef(
target.building->production->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.durationSeconds = recipe->durationSeconds;
return info;
}

View File

@@ -0,0 +1,20 @@
#pragma once
#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 currently in production.
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;
};

118
src/ui/selection/BarRow.cpp Normal file
View File

@@ -0,0 +1,118 @@
#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
{
const QColor fill = m_fillColor.isValid()
? m_fillColor
: palette().color(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);
}

36
src/ui/selection/BarRow.h Normal file
View File

@@ -0,0 +1,36 @@
#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

@@ -0,0 +1,38 @@
#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

@@ -0,0 +1,30 @@
#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

@@ -0,0 +1,103 @@
#include "BufferSection.h"
#include <vector>
#include <QVBoxLayout>
#include "Building.h"
#include "DisplayName.h"
#include "ItemChipRow.h"
#include "SectionBox.h"
namespace
{
int findPerCycle(const std::map<std::string, int>& perCycle, const std::string& itemId)
{
const std::map<std::string, int>::const_iterator it = perCycle.find(itemId);
return (it != perCycle.end()) ? it->second : 0;
}
} // namespace
BufferSection::BufferSection(const SelectionContext& context, QWidget* parent)
: QWidget(parent)
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(6);
m_inputSection = new SectionBox(tr("Input buffers"), this);
m_inputChips = new ItemChipRow(context.itemIcons, m_inputSection);
m_inputSection->getContentLayout()->addWidget(m_inputChips);
m_outputSection = new SectionBox(tr("Output buffer"), this);
m_outputChips = new ItemChipRow(context.itemIcons, m_outputSection);
m_outputSection->getContentLayout()->addWidget(m_outputChips);
layout->addWidget(m_inputSection);
layout->addWidget(m_outputSection);
}
void BufferSection::setBuffers(const Building& building,
const std::map<std::string, int>& perCycleInputs,
const std::map<std::string, int>& perCycleOutputs)
{
std::vector<ItemChipRow::Entry> inputs;
for (const std::pair<const ItemType, int>& entry : building.inputBuffer.counts)
{
ItemChipRow::Entry chip;
chip.itemId = entry.first.id;
chip.countText = QString::number(entry.second);
const int perCycle = findPerCycle(perCycleInputs, entry.first.id);
if (perCycle > 0)
{
chip.subLine = tr("/ %1 per cycle").arg(perCycle);
}
inputs.push_back(chip);
}
// Output-side items are the buffered ones 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> outputCounts;
for (const Item& item : building.outputBuffer.items)
{
outputCounts[item.type.id]++;
}
for (const std::vector<BeltItemSlot>& lane : building.emergingItems)
{
for (const BeltItemSlot& slot : lane)
{
outputCounts[slot.item.type.id]++;
}
}
// A configured building lists everything its cycle produces, so an output the player
// is waiting for reads as 0 rather than being absent.
for (const std::pair<const std::string, int>& entry : perCycleOutputs)
{
outputCounts.emplace(entry.first, 0);
}
std::vector<ItemChipRow::Entry> outputs;
for (const std::pair<const std::string, int>& entry : outputCounts)
{
ItemChipRow::Entry chip;
chip.itemId = entry.first;
// 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(entry.second).arg(building.outputBuffer.capacity)
: QString::number(entry.second);
chip.subLine = QString::fromStdString(toDisplayName(entry.first));
outputs.push_back(chip);
}
m_inputChips->setEntries(inputs);
m_outputChips->setEntries(outputs);
m_inputSection->setVisible(!inputs.empty());
m_outputSection->setVisible(!outputs.empty());
setVisible(!inputs.empty() || !outputs.empty());
}

View File

@@ -0,0 +1,39 @@
#pragma once
#include <map>
#include <string>
#include <QWidget>
#include "SelectionContext.h"
struct Building;
class ItemChipRow;
class SectionBox;
// The input and output buffer contents of one building, each a captioned section of item
// chips (REQ-UI-SINGLE-SELECTION).
//
// Counting what is in the buffers is the same for every building type, so it happens
// here; what a cycle consumes and produces is not, so the owning content supplies those
// per-cycle amounts. An item with no entry in the maps is shown without a denominator,
// and a section holding nothing is not shown at all.
class BufferSection : public QWidget
{
Q_OBJECT
public:
explicit BufferSection(const SelectionContext& context, QWidget* parent = nullptr);
// perCycleInputs and perCycleOutputs map an item id to the amount one production
// cycle consumes or produces. Both may be empty, for a building that runs no cycle.
void setBuffers(const Building& building,
const std::map<std::string, int>& perCycleInputs,
const std::map<std::string, int>& perCycleOutputs);
private:
SectionBox* m_inputSection;
ItemChipRow* m_inputChips;
SectionBox* m_outputSection;
ItemChipRow* m_outputChips;
};

View File

@@ -0,0 +1,84 @@
#include "BufferedBuildingContent.h"
#include <vector>
#include <QVBoxLayout>
#include "Building.h"
#include "BufferSection.h"
#include "BuildingTarget.h"
#include "FactoryQueries.h"
#include "ProductionSection.h"
#include "RecipeSummaryRow.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;
}
} // 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_buffers = new BufferSection(context, this);
m_production = new ProductionSection(this);
getRuntimeLayout()->addWidget(m_buffers);
getRuntimeLayout()->addWidget(m_production);
}
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);
m_buffers->setBuffers(*target.building, cycle.perCycleInputs, cycle.perCycleOutputs);
m_production->setProduction(cycle.runsProduction, *target.building,
cycle.durationSeconds,
getContext().sim->getCurrentTick());
}

View File

@@ -0,0 +1,62 @@
#pragma once
#include <map>
#include <string>
#include "BuildingId.h"
#include "SelectionContent.h"
struct BuildingTarget;
class BufferSection;
class ProductionSection;
class RecipeSummaryRow;
// 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 identity and 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;
// 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 while an auto-recipe building sits between cycles, when no single recipe
// names a cycle time; the progress line then reads "idle".
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;
BuildingId m_id;
RecipeSummaryRow* m_recipeSummary;
BufferSection* m_buffers;
ProductionSection* m_production;
};

View File

@@ -0,0 +1,34 @@
#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

@@ -0,0 +1,45 @@
#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

@@ -0,0 +1,78 @@
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}/BufferSection.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}/BufferSection.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

@@ -0,0 +1,54 @@
#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

@@ -0,0 +1,31 @@
#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

@@ -0,0 +1,29 @@
#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

@@ -0,0 +1,25 @@
#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

@@ -0,0 +1,32 @@
#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

@@ -0,0 +1,31 @@
#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

@@ -0,0 +1,18 @@
#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

@@ -0,0 +1,13 @@
#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

@@ -0,0 +1,19 @@
#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

@@ -0,0 +1,15 @@
#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

@@ -0,0 +1,105 @@
#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

@@ -0,0 +1,33 @@
#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

@@ -0,0 +1,60 @@
#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_stockSection = new SectionBox(tr("Building blocks"), this);
m_stockChips = new ItemChipRow(context.itemIcons, m_stockSection);
m_stockSection->getContentLayout()->addWidget(m_stockChips);
m_hpBar = new BarRow(tr("HP"), this);
getRuntimeLayout()->addWidget(m_stockSection);
getRuntimeLayout()->addWidget(m_hpBar);
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

@@ -0,0 +1,32 @@
#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

@@ -0,0 +1,72 @@
#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

@@ -0,0 +1,28 @@
#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

@@ -0,0 +1,78 @@
#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);
m_chips.push_back(chip);
}
}

View File

@@ -0,0 +1,44 @@
#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

@@ -0,0 +1,99 @@
#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

@@ -0,0 +1,31 @@
#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

@@ -0,0 +1,48 @@
#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

@@ -0,0 +1,35 @@
#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

@@ -0,0 +1,51 @@
#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

@@ -0,0 +1,26 @@
#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

@@ -0,0 +1,57 @@
#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

@@ -0,0 +1,38 @@
#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

@@ -0,0 +1,108 @@
#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;
} // 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
m_layout->addWidget(new QLabel(QString(rightArrow), this));
}
addAmounts(outputs);
if (durationSeconds > 0.0)
{
const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT
m_layout->addWidget(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));
m_layout->addWidget(iconLabel);
}
else
{
m_layout->addWidget(
new QLabel(QString::fromStdString(entry.itemId), this));
}
m_layout->addWidget(new QLabel(QString::number(entry.amount), this));
}
}

View File

@@ -0,0 +1,52 @@
#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

@@ -0,0 +1,50 @@
#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

@@ -0,0 +1,28 @@
#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

@@ -0,0 +1,222 @@
#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;
} // 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));
cardLayout->addWidget(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 StatusLightVisuals& colors = m_context.visuals->statusLight;
// 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).
const bool isSalvageBay = (building.type == BuildingType::SalvageBay);
switch (*status)
{
case ProductionStatus::Unconfigured:
setSlot(colors.grey, tr("no recipe"));
break;
case ProductionStatus::Producing:
setSlot(colors.green, isSalvageBay ? tr("holding scrap") : tr("producing"));
break;
case ProductionStatus::Starved:
setSlot(colors.red, isSalvageBay ? tr("empty") : tr("missing input"));
break;
case ProductionStatus::Blocked:
setSlot(colors.yellow, tr("output full"));
break;
}
}
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

@@ -0,0 +1,104 @@
#pragma once
#include <optional>
#include <QColor>
#include <QPixmap>
#include <QString>
#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();
// 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

@@ -0,0 +1,187 @@
#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

@@ -0,0 +1,70 @@
#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

@@ -0,0 +1,29 @@
#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

@@ -0,0 +1,35 @@
#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();
}

View File

@@ -0,0 +1,15 @@
#pragma once
#include <QString>
#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);

View File

@@ -0,0 +1,67 @@
#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);
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

@@ -0,0 +1,28 @@
#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

@@ -0,0 +1,102 @@
#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;
}

View File

@@ -0,0 +1,34 @@
#pragma once
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
class QPushButton;
class RecipeSelectionControl;
class SectionBox;
class ShipLayoutPreview;
// The card for a Shipyard (REQ-UI-SELECTION-CONTENT): the schematic selection, the
// module layout preview and its Configure button (REQ-MOD-UI-PREVIEW), plus the buffers
// and production progress every buffered building shows.
//
// It is the one card whose cycle is not a recipe: a shipyard's materials and production
// time are its schematic's plus every placed module's (REQ-BLD-SHIPYARD).
class ShipyardContent : public BufferedBuildingContent
{
Q_OBJECT
public:
ShipyardContent(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_schematicControl;
SectionBox* m_layoutSection;
ShipLayoutPreview* m_layoutPreview;
QPushButton* m_configureButton;
};

View File

@@ -0,0 +1,211 @@
#include "SplitterContent.h"
#include <algorithm>
#include <optional>
#include <set>
#include <string>
#include <vector>
#include <QLabel>
#include <QListWidget>
#include <QVBoxLayout>
#include "BeltSystem.h"
#include "BuildingTarget.h"
#include "ClearBeltControl.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "EventManager.h"
#include "FactoryQueries.h"
#include "GameConfig.h"
#include "ItemType.h"
#include "Rotation.h"
#include "SelectionNames.h"
#include "Simulation.h"
namespace
{
// Height cap on a filter list, so two of them plus the rest of the card still fit.
const int kFilterListHeightPx = 100;
QString getRotationLabel(Rotation rotation)
{
// Written as code points because the sources are read as ASCII by the compiler.
const QChar upArrow(0x2191); // U+2191 UPWARDS ARROW
const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW
const QChar downArrow(0x2193); // U+2193 DOWNWARDS ARROW
const QChar leftArrow(0x2190); // U+2190 LEFTWARDS ARROW
switch (rotation)
{
case Rotation::North: return QObject::tr("North (%1)").arg(upArrow);
case Rotation::East: return QObject::tr("East (%1)").arg(rightArrow);
case Rotation::South: return QObject::tr("South (%1)").arg(downArrow);
case Rotation::West: return QObject::tr("West (%1)").arg(leftArrow);
}
return QString();
}
// Every item type the economy knows, from both sides of every recipe.
std::vector<std::string> getAllItemIds(const RecipesConfig& recipes)
{
std::set<std::string> seen;
for (const RecipeDef& recipe : recipes.recipes)
{
for (const RecipeIngredient& ingredient : recipe.inputs)
{
seen.insert(ingredient.item);
}
for (const RecipeOutput& output : recipe.outputs)
{
seen.insert(output.item);
}
}
return std::vector<std::string>(seen.begin(), seen.end());
}
std::vector<ItemType> collectCheckedItems(const QListWidget* list)
{
std::vector<ItemType> filter;
for (int row = 0; row < list->count(); ++row)
{
const QListWidgetItem* item = list->item(row);
if (item->checkState() == Qt::Checked)
{
filter.push_back(ItemType{ item->text().toStdString() });
}
}
return filter;
}
} // namespace
SplitterContent::SplitterContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, asConstructionSite(context, request.buildings.front()),
parent)
, m_id(request.buildings.front())
, m_isSite(asConstructionSite(context, m_id).has_value())
, m_tile(0, 0)
{
m_filterALabel = new QLabel(this);
m_filterAList = new QListWidget(this);
m_filterBLabel = new QLabel(this);
m_filterBList = new QListWidget(this);
m_filterAList->setMaximumHeight(kFilterListHeightPx);
m_filterBList->setMaximumHeight(kFilterListHeightPx);
getConfigurationLayout()->addWidget(m_filterALabel);
getConfigurationLayout()->addWidget(m_filterAList);
getConfigurationLayout()->addWidget(m_filterBLabel);
getConfigurationLayout()->addWidget(m_filterBList);
getRuntimeLayout()->addWidget(
new ClearBeltControl(context, request.buildings, this));
// Populated once, here, rather than on every refresh: re-checking the boxes at 30 Hz
// would fight the player's clicks.
populateFilters();
connect(m_filterAList, &QListWidget::itemChanged,
this, [this]() { applyFilters(); });
connect(m_filterBList, &QListWidget::itemChanged,
this, [this]() { applyFilters(); });
}
void SplitterContent::refreshConfiguration()
{
const BuildingTarget target = resolveBuildingTarget(getContext(), m_id);
if (!target.isValid())
{
return;
}
setBuildingIdentity(target.type, getBuildingTypeName(target.type));
}
void SplitterContent::populateFilters()
{
const BuildingTarget target = resolveBuildingTarget(getContext(), m_id);
if (!target.isValid())
{
return;
}
// An operational splitter's outputs and filters live in the belt subsystem, keyed by
// tile; a site's are stored on the site itself (REQ-BLD-SITE-CONFIG).
std::optional<BeltSystem::SplitterInfo> info;
if (m_isSite)
{
info = getSiteSplitterInfo(getContext().sim->getFactoryState(),
*getContext().config, m_id);
}
else
{
m_tile = target.anchor;
info = getContext().sim->getBelts().getSplitterInfo(m_tile);
}
if (!info.has_value())
{
m_filterALabel->hide();
m_filterAList->hide();
m_filterBLabel->hide();
m_filterBList->hide();
return;
}
const std::vector<std::string> itemIds = getAllItemIds(getContext().config->recipes);
auto fillList = [&](QListWidget* list, QLabel* label, const QString& directionLabel,
const std::vector<ItemType>& filter)
{
label->setText(tr("%1 filter (empty = all):").arg(directionLabel));
list->blockSignals(true);
list->clear();
for (const std::string& itemId : itemIds)
{
// Only implicitly unlocked item types are offered (REQ-LOCK-UI-SPLITTER).
if (!getContext().sim->isItemUnlocked(itemId)) { continue; }
QListWidgetItem* row =
new QListWidgetItem(QString::fromStdString(itemId), list);
const bool checked = !filter.empty()
&& std::find(filter.begin(), filter.end(), ItemType{ itemId })
!= filter.end();
row->setCheckState(checked ? Qt::Checked : Qt::Unchecked);
row->setFlags(row->flags() | Qt::ItemIsUserCheckable);
}
list->blockSignals(false);
label->show();
list->show();
};
fillList(m_filterAList, m_filterALabel, getRotationLabel(info->outputA),
info->filterA);
fillList(m_filterBList, m_filterBLabel, getRotationLabel(info->outputB),
info->filterB);
}
void SplitterContent::applyFilters()
{
if (m_isSite)
{
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = m_id;
command->filterA = collectCheckedItems(m_filterAList);
command->filterB = collectCheckedItems(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
return;
}
std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>();
command->tile = m_tile;
command->filterA = collectCheckedItems(m_filterAList);
command->filterB = collectCheckedItems(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <QPoint>
#include "BuildingId.h"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class QLabel;
class QListWidget;
// The card for a Splitter (REQ-UI-SELECTION-CONTENT): its two per-output item filters
// (REQ-BLD-SPLITTER) plus the clear action every belt-subsystem tile has
// (REQ-UI-BELT-CLEAR).
//
// The filters are configuration, so they are shown for a construction site too and set
// through the site's own command (REQ-BLD-SITE-CONFIG); the clear action is runtime and
// therefore is not, because a site's tile is not registered with the belt subsystem yet.
class SplitterContent : public SelectionContent
{
Q_OBJECT
public:
SplitterContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshConfiguration() override;
void refreshRuntime() override {}
private:
// Sends the checked items of both lists as the splitter's new filters. Routed to the
// site command or the live command depending on what the id names.
void applyFilters();
// Rebuilds both lists from the splitter's current outputs and filters. Only run when
// the lists are not already populated, so a per-tick refresh cannot uncheck a box
// the player is in the middle of clicking.
void populateFilters();
BuildingId m_id;
bool m_isSite;
QPoint m_tile;
QLabel* m_filterALabel;
QListWidget* m_filterAList;
QLabel* m_filterBLabel;
QListWidget* m_filterBList;
};

View File

@@ -0,0 +1,54 @@
#include "StatRow.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QPalette>
namespace
{
// Left inset of an indented row, in device-independent pixels.
const int kIndentPx = 12;
} // namespace
StatRow::StatRow(const QString& label, QWidget* parent)
: QWidget(parent)
{
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(8);
m_labelLabel = new QLabel(label, this);
m_valueLabel = new QLabel(this);
m_valueLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
layout->addWidget(m_labelLabel);
layout->addStretch(1);
layout->addWidget(m_valueLabel);
}
void StatRow::setLabel(const QString& label)
{
m_labelLabel->setText(label);
}
void StatRow::setValue(const QString& value)
{
m_valueLabel->setText(value);
}
void StatRow::setValueEmphasized(bool emphasized)
{
QPalette valuePalette = m_valueLabel->palette();
valuePalette.setColor(QPalette::WindowText,
palette().color(emphasized ? QPalette::Highlight
: QPalette::WindowText));
m_valueLabel->setPalette(valuePalette);
}
void StatRow::setIndented(bool indented)
{
layout()->setContentsMargins(indented ? kIndentPx : 0, 0, 0, 0);
}

View File

@@ -0,0 +1,33 @@
#pragma once
#include <QString>
#include <QWidget>
class QLabel;
// One label/value line: the name on the left, the value hard right
// (REQ-UI-SELECTION-CARD). The panel's plainest part, shared by the ship and station
// stats, the debris scrap, the HQ block stock, and the multi-selection's total cost.
//
// Like the other card parts it knows nothing about the simulation: it is given the two
// strings and lays them out.
class StatRow : public QWidget
{
Q_OBJECT
public:
explicit StatRow(const QString& label, QWidget* parent = nullptr);
void setLabel(const QString& label);
void setValue(const QString& value);
// Draws the value in the palette's highlight color rather than its text color, for
// the one value a card is really about.
void setValueEmphasized(bool emphasized);
// Indents the row, so it reads as belonging to the row above it -- the scrap total
// under a debris count (REQ-UI-FIELD-MULTI-SELECTION).
void setIndented(bool indented);
private:
QLabel* m_labelLabel;
QLabel* m_valueLabel;
};

View File

@@ -0,0 +1,83 @@
#include "StationContent.h"
#include <QVBoxLayout>
#include "BarRow.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "ModuleOwnerComponent.h"
#include "Simulation.h"
#include "StatRow.h"
#include "WeaponComponent.h"
StationContent::StationContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: SelectionContent(context, std::nullopt, parent)
, m_entity(request.actors.front())
{
m_hpBar = new BarRow(tr("HP"), this);
m_damageRow = new StatRow(tr("Damage"), this);
m_rangeRow = new StatRow(tr("Range"), this);
m_fireRateRow = new StatRow(tr("Fire rate"), this);
getRuntimeLayout()->addWidget(m_hpBar);
getRuntimeLayout()->addWidget(m_damageRow);
getRuntimeLayout()->addWidget(m_rangeRow);
getRuntimeLayout()->addWidget(m_fireRateRow);
EntityAdmin& admin = context.sim->getAdmin();
const bool isEnemy = admin.isValid(m_entity)
&& admin.hasAll<FactionComponent>(m_entity)
&& admin.get<FactionComponent>(m_entity).isEnemy;
setIdentity(QPixmap(), isEnemy ? tr("Enemy Defence Station")
: tr("Player Defence Station"));
}
void StationContent::refreshRuntime()
{
EntityAdmin& admin = getContext().sim->getAdmin();
if (!admin.isValid(m_entity) || !admin.hasAll<HealthComponent>(m_entity))
{
return;
}
const HealthComponent& health = admin.get<HealthComponent>(m_entity);
const double hpFraction = (health.maxHp > 0.0f)
? static_cast<double>(health.hp) / health.maxHp
: 0.0;
m_hpBar->setValue(hpFraction, tr("%1 / %2")
.arg(static_cast<int>(health.hp + 0.5f))
.arg(static_cast<int>(health.maxHp + 0.5f)));
// A station's weapons are child module entities pointing back at it, so its damage,
// range and fire rate are read off those rather than off the station itself.
float totalDamage = 0.0f;
float maxRange = 0.0f;
float maxFireRateHz = 0.0f;
bool hasWeapon = false;
const entt::entity station = m_entity;
admin.forEach<ModuleOwnerComponent, WeaponComponent>(
[&](entt::entity /*child*/, const ModuleOwnerComponent& owner,
const WeaponComponent& weapon)
{
if (owner.owner != station) { return; }
hasWeapon = true;
totalDamage += weapon.damage;
if (weapon.range_tiles > maxRange) { maxRange = weapon.range_tiles; }
if (weapon.fireRateHz > maxFireRateHz) { maxFireRateHz = weapon.fireRateHz; }
});
m_damageRow->setVisible(hasWeapon);
m_rangeRow->setVisible(hasWeapon);
m_fireRateRow->setVisible(hasWeapon);
if (hasWeapon)
{
m_damageRow->setValue(
QString::number(static_cast<double>(totalDamage), 'f', 1));
m_rangeRow->setValue(tr("%1 tiles")
.arg(QString::number(static_cast<double>(maxRange), 'f', 1)));
m_fireRateRow->setValue(tr("%1 /s")
.arg(QString::number(static_cast<double>(maxFireRateHz), 'f', 1)));
}
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include "entt/entity/entity.hpp"
#include "SelectionContent.h"
#include "SelectionContentFactory.h"
class BarRow;
class StatRow;
// The card for one selected defence station, player or enemy
// (REQ-UI-STATION-STATS-PANEL): its HP plus the combined damage, range and fire rate of
// the weapon modules mounted on it.
class StationContent : public SelectionContent
{
Q_OBJECT
public:
StationContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
void refreshRuntime() override;
private:
entt::entity m_entity;
BarRow* m_hpBar;
StatRow* m_damageRow;
StatRow* m_rangeRow;
StatRow* m_fireRateRow;
};

View File

@@ -0,0 +1,68 @@
#include "StatusPill.h"
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPixmap>
#include <QRectF>
namespace
{
// Diameter of the status dot, in device-independent pixels.
const int kDotSizePx = 8;
QPixmap renderDot(const QColor& fill, const QColor& outline)
{
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
QPixmap pixmap(static_cast<int>(kDotSizePx * dpr),
static_cast<int>(kDotSizePx * dpr));
pixmap.setDevicePixelRatio(dpr);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setPen(outline.isValid() ? QPen(outline) : QPen(Qt::NoPen));
painter.setBrush(fill);
// Inset by half the pen width so the outline stays inside the pixmap.
painter.drawEllipse(QRectF(0.5, 0.5, kDotSizePx - 1.0, kDotSizePx - 1.0));
return pixmap;
}
} // namespace
StatusPill::StatusPill(QWidget* parent)
: QWidget(parent)
{
QHBoxLayout* layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(4);
m_dotLabel = new QLabel(this);
m_captionLabel = new QLabel(this);
layout->addWidget(m_dotLabel);
layout->addWidget(m_captionLabel);
hide();
}
void StatusPill::setStatus(const QColor& dotColor, const QColor& outlineColor,
const QString& caption)
{
if (dotColor.isValid())
{
m_dotLabel->setPixmap(renderDot(dotColor, outlineColor));
m_dotLabel->show();
}
else
{
m_dotLabel->hide();
}
m_captionLabel->setText(caption);
m_captionLabel->setVisible(!caption.isEmpty());
setVisible(dotColor.isValid() || !caption.isEmpty());
}

View File

@@ -0,0 +1,29 @@
#pragma once
#include <QColor>
#include <QString>
#include <QWidget>
class QLabel;
// The card header's right slot (REQ-UI-SELECTION-CARD): a colored dot with a short
// caption beside it. It carries a building's production status
// (REQ-UI-SELECTION-STATUS), a ship's current behavior (REQ-UI-SHIP-BEHAVIOR), or an
// aggregated selection's object count (REQ-UI-SELECTION-AGGREGATE) -- never more than
// one of them, which is why they share one part.
class StatusPill : public QWidget
{
Q_OBJECT
public:
explicit StatusPill(QWidget* parent = nullptr);
// An invalid dot color leaves the dot off, for the slots that are a plain caption.
// An empty caption hides the pill entirely.
void setStatus(const QColor& dotColor, const QColor& outlineColor,
const QString& caption);
private:
QLabel* m_dotLabel;
QLabel* m_captionLabel;
};

View File

@@ -0,0 +1,17 @@
#include "StorageContent.h"
#include "BuildingTarget.h"
StorageContent::StorageContent(const SelectionContext& context,
const SelectionRequest& request, QWidget* parent)
: BufferedBuildingContent(context, request.buildings.front(), parent)
{
}
BufferedBuildingContent::CycleInfo StorageContent::getCycleInfo(
const BuildingTarget& /*target*/) const
{
// No recipe, no cycle: the card shows the output buffer alone, with no per-cycle
// denominators and no production progress (REQ-BLD-SALVAGE-BAY).
return CycleInfo();
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include "BufferedBuildingContent.h"
#include "SelectionContentFactory.h"
// The card for a Salvage Bay (REQ-UI-SELECTION-CONTENT): its held scrap and nothing
// else. It has no recipe to configure and runs no production cycle
// (REQ-BLD-SALVAGE-BAY), so it is the buffered-building card with both of those left
// out -- its header status says whether it is holding scrap rather than whether it is
// producing (REQ-UI-SELECTION-STATUS).
class StorageContent : public BufferedBuildingContent
{
Q_OBJECT
public:
StorageContent(const SelectionContext& context, const SelectionRequest& request,
QWidget* parent = nullptr);
protected:
CycleInfo getCycleInfo(const BuildingTarget& target) const override;
};