Compare commits
83 Commits
refactorin
...
a5c51f08f8
| Author | SHA1 | Date | |
|---|---|---|---|
| a5c51f08f8 | |||
| 3c4688bdb6 | |||
| d7c6734a2b | |||
| 1604dc02a2 | |||
| b346f3dfc1 | |||
| b634da70fb | |||
| 37d9166348 | |||
| fe69921b8e | |||
| 3e79b09fec | |||
| 77bbd58d02 | |||
| 02f2314588 | |||
| 246cfc3935 | |||
| 89e984ec76 | |||
| 90e40dddbc | |||
| 668ce0fcb8 | |||
| 7ddb4a1bb5 | |||
| 3b37b0ecf8 | |||
| 98deab932a | |||
| 08d8b0dd90 | |||
| 9a3b6c10d6 | |||
| cd31af2611 | |||
| fd6a7c5815 | |||
| 18cfe238f6 | |||
| 2cbcf1554f | |||
| c1af58d80c | |||
| f39fe9a118 | |||
| e5dcb9de5f | |||
| dc83add5c6 | |||
| 3a1951559d | |||
| 26d7448492 | |||
| 202f583067 | |||
| d5ab44b9bf | |||
| bb1ffab8fc | |||
| e1445fe508 | |||
| caa810f66d | |||
| f6df95abb2 | |||
| fa9dbd62ad | |||
| 2af09d9eb1 | |||
| 4f7fdb8a4c | |||
| 949937d2c2 | |||
| 4c166bf47f | |||
| 60260540cd | |||
| 1f4503176b | |||
| fd85e8e10a | |||
| 114a43b205 | |||
| d87d063b10 | |||
| 537597c854 | |||
| 9c3be0fbd0 | |||
| 58b94223f7 | |||
| 1fb63cce4e | |||
| 0b7e94b4e4 | |||
| 0408336cf9 | |||
| 46932e4abf | |||
| 0edea5d961 | |||
| 60cc187d92 | |||
| 3990351a16 | |||
| bd344e4fbe | |||
| 64c344c3a3 | |||
| 02c7fed9b4 | |||
| 5d4a975384 | |||
| 475df0e5fd | |||
| 61634f6fd2 | |||
| c8ff7da345 | |||
| d664ab54cc | |||
| 906000b0e9 | |||
| d9ef6aa728 | |||
| b44a85685e | |||
| edd1c31785 | |||
| 785ce3ebfe | |||
| 7017f8b4dc | |||
| 77a842f884 | |||
| d5ba72d554 | |||
| 83177729e9 | |||
| a8e933b7f2 | |||
| e02e323cb2 | |||
| b4e622daa5 | |||
| 1150985c1f | |||
| 594c3b93c5 | |||
| 932b57720c | |||
| ca727bef35 | |||
| 553a7e0701 | |||
| af6828c348 | |||
| 3671e1d7e6 |
@@ -5,6 +5,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Interaction
|
## Interaction
|
||||||
|
|
||||||
* ONLY modify code or other files if explicitly asked to do so
|
* ONLY modify code or other files if explicitly asked to do so
|
||||||
|
* keep the tone professional, brief and to the point — brevity applies to prose and
|
||||||
|
preamble, not to the substance of an objection or a design rationale
|
||||||
|
* be critical: where there is a concrete technical reason to disagree, name it once;
|
||||||
|
if the user reaffirms, proceed with their call without re-litigating
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -31,6 +35,15 @@ keep the citation accurate.
|
|||||||
## Coding Guidelines
|
## Coding Guidelines
|
||||||
|
|
||||||
* avoid duplicate code
|
* avoid duplicate code
|
||||||
|
* when planning a change, weigh the long-term maintainability of the codebase instead of
|
||||||
|
defaulting to the lowest-effort patch — but no speculative generality: never build or
|
||||||
|
prepare for functionality we may never need. If the maintainable solution is much larger
|
||||||
|
than the request, say so and let the user decide the scope.
|
||||||
|
* class layout: static members first, then non-static; within each, public, then protected,
|
||||||
|
then private (Qt `slots:`/`signals:` are ordinary non-static access groups). Inside an
|
||||||
|
access group the order is: nested types, static constants, aliases, methods, fields,
|
||||||
|
friends. Out-of-line method definitions follow the declaration order. Applies to new
|
||||||
|
classes and to files being edited anyway — don't reorder existing headers just to comply.
|
||||||
* do not use the "auto" keyword, with two exceptions:
|
* do not use the "auto" keyword, with two exceptions:
|
||||||
* **named local lambdas** — a lambda's type is unnameable, and `std::function`
|
* **named local lambdas** — a lambda's type is unnameable, and `std::function`
|
||||||
is not an acceptable substitute in per-tick code because it adds a heap
|
is not an acceptable substitute in per-tick code because it adds a heap
|
||||||
@@ -47,6 +60,13 @@ keep the citation accurate.
|
|||||||
* don't use abbreviations, except very common ones ("s" for seconds, "min", "max", etc.)
|
* don't use abbreviations, except very common ones ("s" for seconds, "min", "max", etc.)
|
||||||
* if a variable holds a value that has a unit or if a function returns a value that has a unit, append that unit to the name (e.g. "m_shipVelocity_mps", "getAcceleration_mpss()")
|
* if a variable holds a value that has a unit or if a function returns a value that has a unit, append that unit to the name (e.g. "m_shipVelocity_mps", "getAcceleration_mpss()")
|
||||||
* always enclose scopes in braces
|
* always enclose scopes in braces
|
||||||
|
* keep source files ASCII-only. A non-ASCII character needed at runtime (a glyph in a
|
||||||
|
UI string, a symbol drawn on a widget) is written as its code point with a comment
|
||||||
|
naming it (`const QChar shiftGlyph(0x21E7); // U+21E7 UPWARDS WHITE ARROW`), never
|
||||||
|
as a literal character: MSVC 2017 does not read the sources as UTF-8 by default and
|
||||||
|
silently mangles them. Never round-trip a source file through
|
||||||
|
`Get-Content`/`Set-Content` either: Windows PowerShell reads it as ANSI and writes
|
||||||
|
it back double-encoded with a BOM. Use the Edit/Write tools.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -83,6 +103,15 @@ output directories and copies the Qt DLLs.
|
|||||||
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
|
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
|
||||||
`--replay <file>` for view-only playback of a recorded run.
|
`--replay <file>` for view-only playback of a recorded run.
|
||||||
|
|
||||||
|
**Visual verification is the user's job.** Screen-capturing the app window does not
|
||||||
|
work here: `CopyFromScreen` and `PrintWindow` both return a blank white client area
|
||||||
|
even while the app is running and rendering normally, because the capture cannot read
|
||||||
|
the composited surface of the `QOpenGLWidget`-backed window. A blank capture therefore
|
||||||
|
says nothing about whether the UI works, so do not read one as a regression and do not
|
||||||
|
try to work around it. To check a UI change: build, run the tests, launch the app, and
|
||||||
|
ask the user to look at it. Redirecting the process's stdout/stderr to a file does
|
||||||
|
work and is worth checking for Qt warnings.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Catch2, single executable, links `lib` only — no QApplication, no display.
|
Catch2, single executable, links `lib` only — no QApplication, no display.
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ deconstruct_tint = "#ff000033" # deconstruct-mode hover tint
|
|||||||
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT)
|
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT)
|
||||||
tile_highlight = "#ffffff22" # tile under cursor
|
tile_highlight = "#ffffff22" # tile under cursor
|
||||||
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
|
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
|
||||||
copy_config = "#33ccff66" # copy-settings eligible-target tint + copy/paste flash (REQ-BLD-COPY-CONFIG-FEEDBACK)
|
config_transfer = "#33ccff66" # blueprint ghost over a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER)
|
||||||
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
|
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
|
||||||
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
|
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
|
||||||
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)
|
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ Schematic drops: when an enemy station set is destroyed, the simulation generate
|
|||||||
|
|
||||||
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
|
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
|
||||||
|
|
||||||
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonGrid` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonGrid`).
|
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonBar` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonBar`).
|
||||||
|
|
||||||
### Reading Simulation State
|
### Reading Simulation State
|
||||||
|
|
||||||
@@ -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 grid. 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`.
|
||||||
|
|
||||||
@@ -329,7 +330,11 @@ Buildings and the belt subsystem stay outside any entity model regardless of wha
|
|||||||
|
|
||||||
## Rendering
|
## Rendering
|
||||||
|
|
||||||
The game world is rendered by a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
|
The game world is drawn into a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
|
||||||
|
|
||||||
|
The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line, and it is exact: the renderer draws everything positioned in tiles, while everything positioned in pixels — the pause and deconstruct vignettes, the replay overlay, the debug stats panel — stays with the widget. A useful consequence is that the renderer draws no translatable text at all (its text is config-driven glyphs, ASCII port arrows, and numbers), so it needs no `tr()` and no tie to the meta-object system.
|
||||||
|
|
||||||
|
`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else it draws is interaction state the widget owns — the selection, the active build mode, live beams, the box-select rectangle. Those are gathered into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result.
|
||||||
|
|
||||||
### Render Loop
|
### Render Loop
|
||||||
|
|
||||||
@@ -357,9 +362,11 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
|
|||||||
|
|
||||||
### Coordinates and Scrolling
|
### Coordinates and Scrolling
|
||||||
|
|
||||||
- `GameWorldView` holds a continuous `scrollXTiles` (float). A / D input pans this smoothly (REQ-UI-SCROLL).
|
- The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform.
|
||||||
- At the start of `paintEvent`, a single `painter.translate(-scrollXTiles * tilePx, 0)` maps world tile units into widget pixels (`tilePx = 20`, per REQ-GW-TILE-SIZE).
|
- The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed.
|
||||||
- Mouse input converts the other way: `worldX = mouseX / tilePx + scrollXTiles`; apply `floor` for a tile. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles.
|
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
|
||||||
|
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
|
||||||
|
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
|
||||||
|
|
||||||
### Culling
|
### Culling
|
||||||
|
|
||||||
@@ -369,6 +376,8 @@ The renderer iterates only entities and tiles whose world X lies within the visi
|
|||||||
|
|
||||||
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
|
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
|
||||||
|
|
||||||
|
The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same.
|
||||||
|
|
||||||
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
|
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
|
||||||
|
|
||||||
Sketch of `visuals.toml`:
|
Sketch of `visuals.toml`:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -127,15 +127,15 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
|
- REQ-BLD-GHOST: While in builder mode, a ghost of the building is rendered at the tile under the cursor, showing where it would be placed. The ghost is drawn semi-transparently in the building type's own visuals — its `fill` and `outline` colors and `glyph` from `visuals.toml` — so that different building types are visually distinguishable in builder mode rather than all looking alike. When the current cursor position is invalid, the ghost instead uses the distinct "invalid" color (REQ-BLD-PLACE-VALID), which overrides the per-building coloring.
|
||||||
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
|
- REQ-BLD-ROTATE: While in builder mode, pressing Shift+R rotates the ghost 90° clockwise and R rotates it 90° counter-clockwise. Rotation affects the direction of the output port.
|
||||||
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
|
- REQ-BLD-PLACE: Clicking a valid tile in builder mode places a construction site and adds it to the build queue, consuming building blocks from the global stock. (For belts, placement is instead deferred to a drag gesture and happens on mouse release — REQ-BLD-BELT-DRAG.)
|
||||||
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE, and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
|
- REQ-BLD-PLACE-VALID: A placement position is valid only if (a) every footprint cell in the rotated `surface_mask` is satisfied by the underlying terrain — `A` cells coincide with asteroid tiles, `S` cells coincide with space tiles — (b) no footprint cell overlaps an existing placed building or construction site, except as allowed by REQ-BLD-ROTATE-IN-PLACE (builder mode) or REQ-UI-BLUEPRINT-OVERLAP and REQ-UI-BLUEPRINT-TRANSFER (blueprint placement mode), and (c) the player has enough building blocks to afford the building. The ghost (REQ-BLD-GHOST) is rendered in a distinct "invalid" color — overriding its per-building coloring (REQ-BLD-GHOST) — when the current cursor position fails any of these conditions.
|
||||||
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires deconstructing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement (invalid in normal builder mode; skipped in blueprint placement mode). This applies in both normal builder mode and blueprint placement mode; in blueprint placement mode it is evaluated per building in the blueprint independently — buildings in the blueprint whose footprint coincides with an existing same-type building or site are rotated in place, while the remaining buildings in the blueprint are placed as normal construction sites (subject to the usual validity checks and total cost).
|
- REQ-BLD-ROTATE-IN-PLACE: If the ghost's footprint exactly coincides with the footprint of an existing placed building or construction site of the same building type, clicking places no new construction site and consumes no building blocks. Instead, the existing building or site is rotated to match the ghost's rotation. If the target is a construction site, its construction progress is preserved. **Exception:** Tunnel Entries and Tunnel Exits are never rotated in place — re-orienting a tunnel requires deconstructing and re-placing it (REQ-BLD-TUNNEL-MODE). A tunnel ghost whose footprint coincides with an existing tunnel is therefore treated as an ordinary occupied-tile placement, which is invalid. This applies **only in normal builder mode**, including the belt drag of REQ-BLD-BELT-DRAG. Blueprint placement mode never rotates an existing building in place: there, a ghost whose footprint coincides with an existing same-type building or site is a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER), or a compatible overlap that is left untouched (REQ-UI-BLUEPRINT-OVERLAP), or else an ordinary occupied-tile overlap, which is invalid.
|
||||||
- REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile.
|
- REQ-BLD-BELT-DRAG: **Belt drag placement.** For belts, placement is a deferred drag gesture rather than immediate per-tile placement: construction sites are not placed while the cursor hovers new tiles, but only once the player releases the left mouse button. Pressing the left mouse button in the game world while in belt builder mode starts a drag anchored at the tile under the cursor. As the cursor moves, a **rectilinear (L-shaped) path** of belt tiles is computed from the anchor tile to the tile under the cursor: the path first runs along the axis **parallel to the belt's current orientation** (REQ-BLD-ROTATE) — stepping toward the cursor's coordinate on that axis to a corner tile — and then runs along the orthogonal axis to the cursor tile. When the cursor shares the anchor's row or column the path degenerates to a straight line, and when it is on the anchor tile the path is a single tile.
|
||||||
- **Snapping to a building.** When the tile under the cursor is occupied by a non-belt building or construction site (the **target**), the path does not end on that occupied tile. Instead the end tile is the tile **closest to the cursor** (by distance from the cursor position to the tile) among the tiles orthogonally adjacent to the target across one of its **input-capable edges** — any footprint edge that is not one of the target's output ports, i.e. an edge on which the target can accept an incoming item (REQ-MAT-INPUT-PORTS for buildings, REQ-MAT-ACCEPT-DIR for splitters and tunnels). The geometrically closest such tile is **always** used, even if it turns out not to be a valid belt endpoint — in that case it is previewed and applied by the ordinary rules below (invalid color and skipped if occupied by a non-belt building or invalid terrain; re-oriented if it already holds a belt). The rest of the L-shaped path is computed from the anchor to this end tile exactly as above. The end tile's belt direction points **toward the target** (across the shared input edge), overriding the "final tile keeps its incoming step" rule; this applies whether the end tile is a newly placed belt or an existing belt re-oriented in place, and is reflected both in the ghost preview and in the placement on release.
|
- **Snapping to a building.** When the tile under the cursor is occupied by a non-belt building or construction site (the **target**), the path does not end on that occupied tile. Instead the end tile is the tile **closest to the cursor** (by distance from the cursor position to the tile) among the tiles orthogonally adjacent to the target across one of its **input-capable edges** — any footprint edge that is not one of the target's output ports, i.e. an edge on which the target can accept an incoming item (REQ-MAT-INPUT-PORTS for buildings, REQ-MAT-ACCEPT-DIR for splitters and tunnels). The geometrically closest such tile is **always** used, even if it turns out not to be a valid belt endpoint — in that case it is previewed and applied by the ordinary rules below (invalid color and skipped if occupied by a non-belt building or invalid terrain; re-oriented if it already holds a belt). The rest of the L-shaped path is computed from the anchor to this end tile exactly as above. The end tile's belt direction points **toward the target** (across the shared input edge), overriding the "final tile keeps its incoming step" rule; this applies whether the end tile is a newly placed belt or an existing belt re-oriented in place, and is reflected both in the ghost preview and in the placement on release.
|
||||||
- **Rotating during the drag.** Rotating the belt with R / Shift+R (REQ-BLD-ROTATE) while a drag is in progress re-picks the path's primary axis immediately from the new orientation and re-derives the whole path from the anchor to the current cursor tile, without waiting for the next cursor movement.
|
- **Rotating during the drag.** Rotating the belt with R / Shift+R (REQ-BLD-ROTATE) while a drag is in progress re-picks the path's primary axis immediately from the new orientation and re-derives the whole path from the anchor to the current cursor tile, without waiting for the next cursor movement.
|
||||||
- **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step (unless the end tile is snapped to a building, in which case it points into the target — see **Snapping to a building**), and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**.
|
- **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step (unless the end tile is snapped to a building, in which case it points into the target — see **Snapping to a building**), and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**.
|
||||||
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
|
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
|
||||||
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
|
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
|
||||||
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button grid contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-GRID), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
|
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button bar contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-BAR), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
|
||||||
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
|
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
|
||||||
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
|
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
|
||||||
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
|
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
|
||||||
@@ -146,13 +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.)
|
||||||
- REQ-BLD-COPY-CONFIG: **Copy building settings (hold Shift).** While the Shift key is held, the player can copy one building's settings onto other buildings of the same type, so several identical machines can be set up without opening each one's panel. This gesture is available only in the default selection mode; while a builder, blueprint placement, or deconstruct mode is active it is disabled, so it never clashes with placement or demolition clicks.
|
|
||||||
- **Shift + right-click** a building copies its current settings into a temporary cache, along with the building's type. The settings copied are whatever that building type supports: the selected recipe (Miner, Assembler), the selected schematic together with its module layout (Shipyard), or the two output filters (Splitter, REQ-BLD-SPLITTER). Copying succeeds only when there is something to copy — a Miner or Assembler with a recipe selected, a Shipyard with a schematic selected, or any Splitter (whose output filters, even when empty/accept-all, always constitute valid settings). Shift + right-clicking a configurable building with nothing yet selected, a building type that has no settings at all (Smelter, Reprocessing Plant, Salvage Bay, belt/tunnel tiles, the HQ), or empty world space, has no effect and leaves any existing cache unchanged.
|
|
||||||
- **Shift + left-click** a building of the **same type** as the cached one applies the cached settings to it, exactly as if the player had made that selection through the selected building panel — with the same effects as a normal selection change (buffer clearing per REQ-MAT-INPUT-BUFFER and REQ-MAT-OUTPUT-BUFFER, and, for a Shipyard, in-progress cycle cancellation per REQ-BLD-SHIPYARD). This can be repeated on any number of same-type buildings while Shift stays held. Shift + left-clicking a building of a different type than the cached one, any building while the cache is empty, or empty world space, has no effect.
|
|
||||||
- Both operational buildings and construction sites take part as source and target (REQ-BLD-SITE-CONFIG); settings applied to a construction site carry over unchanged when it finishes building.
|
|
||||||
- **Releasing Shift clears the temporary cache.** It is never persisted and does not survive Shift being released; the next copy starts fresh.
|
|
||||||
- Because the cached settings were already valid on a same-type source building, they remain valid and available on the target (a selected recipe/schematic stays unlocked per REQ-LOCK-UI-RECIPE and REQ-LOCK-UI-SCHEMATIC; splitter filter item types stay unlocked per REQ-LOCK-UI-SPLITTER).
|
|
||||||
|
|
||||||
## Building Types
|
## Building Types
|
||||||
|
|
||||||
@@ -160,10 +154,10 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
|
- REQ-BLD-SMELTER: **Smelter** (2×2): Converts ore or scrap into basic materials. No recipe selection required. Inputs, outputs, and rates are defined in `recipes.toml [[recipe]]` entries with `building = "smelter"`.
|
||||||
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
- REQ-BLD-ASSEMBLER: **Assembler** (3×3): The player selects a recipe from the config-defined crafting tree. Produces the selected output item at the rate defined in the corresponding `recipes.toml [[recipe]]` entry with `building = "assembler"`. Only implicitly unlocked recipes are available for selection (REQ-LOCK-UI-RECIPE).
|
||||||
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
|
- REQ-BLD-REPROCESSING: **Reprocessing Plant** (3×3): Consumes scrap per cycle and produces exactly one higher-level intermediate product per cycle via weighted random pick. The input quantity, possible output items, per-output weights, and amounts are defined in `recipes.toml [[recipe]]` entries with `building = "reprocessing_plant"` (`inputs`, `outputs[].item`, `outputs[].amount`, `outputs[].weight`). Weights are normalized at load time; their sum does not need to equal 1. The output is rolled at cycle start (see REQ-MAT-CYCLE); the pool of eligible outputs is restricted to implicitly unlocked item types (REQ-LOCK-REPROCESSING-POOL). The output buffer holds at most one cycle's output — see REQ-MAT-OUTPUT-BUFFER-REPROCESSING.
|
||||||
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration.
|
- REQ-BLD-SHIPYARD: **Shipyard** (4×2): The player selects a schematic. When all required materials — the ship's base materials (`[ship.schematic].materials`) plus the materials of all modules in the configured layout (REQ-MOD-MATERIALS) — are present in its input buffer, the shipyard consumes them and begins a production cycle lasting the ship's base `[ship.schematic].production_time_seconds` plus the sum of production times contributed by all module instances in the configured layout (REQ-MOD-PRODUCTION-TIME). One ship of that type is spawned with the configured modules when the cycle completes. The shipyard cannot start a new cycle while one is in progress. If the player confirms a layout change (REQ-MOD-UI-DIALOG) while a production cycle is in progress, the current cycle is cancelled and all consumed materials are discarded; the shipyard returns to idle with the new layout configuration. Confirming a layout identical to the one already configured is not a change and cancels nothing (REQ-MAT-INPUT-BUFFER).
|
||||||
- REQ-BLD-SALVAGE-BAY: **Salvage Bay** (3×2): A dedicated drop-off point for salvage ships. It has an output buffer whose holding capacity is defined by the `output_buffer_capacity` field of the `salvage_bay` entry in `buildings.toml` (rather than by a production cycle, since the Salvage Bay has no recipe). A ship at the bay hands over one unit of scrap per tick while the buffer has free space; a full buffer blocks further drop-off until space frees up (consistent with the buffer-full semantics of REQ-MAT-OUTPUT-BUFFER). Held scrap is pushed onto connected output belts.
|
- REQ-BLD-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.
|
||||||
@@ -204,7 +198,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
- **Scope.** Direct coupling is the only case in which materials move between buildings without a belt, splitter, or tunnel (REQ-MAT-BELT-ONLY); it bridges only two buildings that are directly adjacent with meeting output/input ports. Transport tiles feeding a building (belt, splitter, or tunnel exit) continue to work through the normal pull, and a producer still hands off to a transport tile placed in the gap as before; a single such tile between two buildings is unaffected by this requirement.
|
- **Scope.** Direct coupling is the only case in which materials move between buildings without a belt, splitter, or tunnel (REQ-MAT-BELT-ONLY); it bridges only two buildings that are directly adjacent with meeting output/input ports. Transport tiles feeding a building (belt, splitter, or tunnel exit) continue to work through the normal pull, and a producer still hands off to a transport tile placed in the gap as before; a single such tile between two buildings is unaffected by this requirement.
|
||||||
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
|
- REQ-MAT-ACCEPT-DIR: A transport tile (belt, splitter, tunnel entry, or tunnel exit) accepts an incoming item only through a non-output edge; an item that would enter through one of the tile's output edges is refused. For a belt or a tunnel entry/exit the sole output edge is the one in its facing direction; for a splitter either of its two output directions is an output edge. This applies both to items pushed from an adjacent transport tile and to items deposited by a building's output port (REQ-MAT-OUTPUT-PORT).
|
||||||
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
|
- REQ-MAT-INPUT-BUFFER: Each building has one input buffer per required input material. Each per-material buffer holds up to twice that material's per-cycle requirement. When the player selects a new recipe or schematic, all items in all input buffers are cleared.
|
||||||
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated).
|
- **Setting a configuration to the value it already holds is a no-op.** Selecting the recipe or schematic already set, or applying a ship layout identical to the one already configured, changes nothing: buffers are not cleared, an in-progress production cycle is not cancelled (REQ-BLD-SHIPYARD), and a construction site's progress and stored settings are untouched. This holds however the setting is applied — through the selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), a blueprint placement (REQ-UI-BLUEPRINT-PLACE), or a blueprint configuration transfer (REQ-UI-BLUEPRINT-TRANSFER). Only a setting that genuinely differs has effects.
|
||||||
|
- REQ-MAT-OUTPUT-BUFFER: Each building has an output buffer that holds up to twice the quantity produced by one production cycle. If the output buffer is full, production stops until space is available. When the player selects a new recipe or schematic, all items in the output buffer are cleared (relevant when the adjacent belt is jammed and items have accumulated). Re-applying a setting the building already has clears nothing, per REQ-MAT-INPUT-BUFFER.
|
||||||
- REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.
|
- REQ-MAT-OUTPUT-BUFFER-REPROCESSING: Exception to REQ-MAT-OUTPUT-BUFFER — the Reprocessing Plant's output buffer holds at most one cycle's output. This prevents exploits where the player stalls the output belt to force the plant to reroll.
|
||||||
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) the cycle's output must fit in the output buffer. For the Reprocessing Plant, the output is picked at cycle start (weighted pick); the cycle only starts if that chosen output fits. On cycle start, inputs are consumed immediately and the production timer begins. On cycle completion, the (already-decided) output is deposited into the output buffer and the building returns to idle.
|
- REQ-MAT-CYCLE: Production cycle lifecycle. When a building is idle, it attempts to start a new cycle: (a) all required inputs must be present in the per-material input buffers, and (b) the cycle's output must fit in the output buffer. For the Reprocessing Plant, the output is picked at cycle start (weighted pick); the cycle only starts if that chosen output fits. On cycle start, inputs are consumed immediately and the production timer begins. On cycle completion, the (already-decided) output is deposited into the output buffer and the building returns to idle.
|
||||||
- REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles.
|
- REQ-MAT-GLOBAL-STOCK: The building blocks stock is the only global inventory. All other materials exist only in building buffers or on belt tiles.
|
||||||
@@ -311,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:
|
||||||
@@ -319,11 +314,11 @@ 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.
|
||||||
|
|
||||||
- REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. This auto-open applies only to the manual schematic selection dialog; schematic changes applied via the copy-settings gesture (REQ-BLD-COPY-CONFIG) or blueprint placement (REQ-UI-BLUEPRINT-PLACE) do **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
|
- REQ-MOD-UI-AUTO-DIALOG: When the player selects a schematic for a shipyard (operational building or construction site) through the schematic selection dialog (REQ-UI-SELECT-BUTTON), and the chosen schematic **differs** from the shipyard's current schematic, the layout configuration dialog (REQ-MOD-UI-DIALOG) opens automatically and immediately once the selection dialog closes — exactly as if the player had then clicked "Configure". Re-selecting the schematic already set does not reopen the dialog. This auto-open applies only to the manual schematic selection dialog; a schematic applied by blueprint placement (REQ-UI-BLUEPRINT-PLACE) does **not** auto-open the dialog. The player may still cancel the auto-opened dialog (REQ-MOD-UI-DIALOG), which leaves the newly selected schematic in place with its default empty layout; the "Configure" button (REQ-MOD-UI-PREVIEW) remains available to open the dialog again later.
|
||||||
|
|
||||||
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip.
|
- REQ-MOD-UI-MODULE-TOOLTIP: Each module selection button in the layout configuration dialog (REQ-MOD-UI-DIALOG) shows a hover tooltip with the descriptive text defined for that module type in `modules.toml` (the optional per-module tooltip field). If a module type defines no tooltip text, its button shows no tooltip. The "Remove" button is not a module type and has no config-defined tooltip.
|
||||||
|
|
||||||
@@ -401,7 +396,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
|
- REQ-LOCK-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
|
||||||
|
|
||||||
- REQ-LOCK-BUILDING: A building type granted by an unlock group (REQ-LOCK-EXPLICIT) is **locked** until that group is awarded. A locked building type has no button in the build button grid (REQ-UI-BUILD-GRID) and cannot be placed, selected as a build tool, or triggered by its build hotkey (REQ-UI-HOTKEYS); its button appears in the grid only once the building type is unlocked. Building types not granted by any unlock group are available from game start. Lock state resets on Restart (REQ-CFG-RELOAD).
|
- REQ-LOCK-BUILDING: A building type granted by an unlock group (REQ-LOCK-EXPLICIT) is **locked** until that group is awarded. A locked building type has no button in the build button bar (REQ-UI-BUILD-BAR) and cannot be placed, selected as a build tool, or triggered by its build hotkey (REQ-UI-HOTKEYS); its button appears in the bar only once the building type is unlocked, at which point the bar re-centers. Building types not granted by any unlock group are available from game start. Lock state resets on Restart (REQ-CFG-RELOAD).
|
||||||
|
|
||||||
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
|
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
|
||||||
|
|
||||||
@@ -433,26 +428,25 @@ 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) containing the three UI panels stacked vertically:
|
The screen is a single column: a header bar across the top and the game world view filling the whole area below it. There is no side panel. All three permanent UI widgets float over the game world — the build button bar (REQ-UI-BUILD-BAR) at its bottom center, the selection panel (REQ-UI-SELECTION-PANEL) at its right edge, centered vertically within the height left above the build button bar and shown only while something is selected, and the controls panel (REQ-UI-CONTROLS-PANEL) in its bottom-left corner, beside the build button bar and rising above it only when the two would overlap. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
|
||||||
|
|
||||||
```
|
```
|
||||||
+--------------------------------------+--------------+
|
+-----------------------------------------------------------+
|
||||||
| Header Bar | |
|
| Header Bar |
|
||||||
+--------------------------------------+ Selected |
|
+-----------------------------------------------------------+
|
||||||
| | Building |
|
| +-----------+ |
|
||||||
| | Panel |
|
| | Selection | |
|
||||||
| +--------------+
|
| Game World | Panel | |
|
||||||
| Game World | Build |
|
| +-----------+ |
|
||||||
| | Button |
|
| |
|
||||||
| | Grid |
|
| +----------+ |
|
||||||
| +--------------+
|
| | Controls | +------------------+ |
|
||||||
| | Blueprint |
|
| | Panel | | Build Button Bar | |
|
||||||
| | Panel |
|
+-+----------+-----+------------------+---------------------+
|
||||||
+--------------------------------------+--------------+
|
(full window width)
|
||||||
(75% width) (25% 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).
|
||||||
@@ -461,9 +455,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), the selection panel (REQ-UI-SELECTION-PANEL), and the controls panel (REQ-UI-CONTROLS-PANEL) float over it.
|
||||||
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
|
- 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), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
|
|
||||||
|
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, the selection panel, REQ-UI-SELECTION-PANEL, and the controls panel, REQ-UI-CONTROLS-PANEL) — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
|
||||||
|
|
||||||
### Game World
|
### Game World
|
||||||
|
|
||||||
@@ -497,12 +496,17 @@ The screen is divided into two columns: a main column (75% width) containing the
|
|||||||
- **A / D** — scroll the view left / right (REQ-UI-SCROLL).
|
- **A / D** — scroll the view left / right (REQ-UI-SCROLL).
|
||||||
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles deconstruct mode: it enters deconstruct mode if inactive, or exits deconstruct mode if already active. (See also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button.)
|
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles deconstruct mode: it enters deconstruct mode if inactive, or exits deconstruct mode if already active. (See also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button.)
|
||||||
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
||||||
- **T** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP).
|
- **C** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
|
||||||
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU).
|
- **V** — re-enter placement mode for the last temporary blueprint created with C (REQ-UI-BLUEPRINT-TEMP). Does nothing when no temporary blueprint exists.
|
||||||
|
- **Ctrl+C** — save the current selection as a named blueprint: opens the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE). Has effect only when at least one player-placeable building is selected; otherwise it does nothing.
|
||||||
|
- **Ctrl+V** — opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), from which a saved blueprint is picked for placement. It is available whenever the game is being played, regardless of the current selection or of which build mode is active, and opens the dialog even when no blueprints are saved yet.
|
||||||
|
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU). While a blueprint dialog is open, Escape closes that dialog instead (REQ-UI-BLUEPRINT-DIALOG).
|
||||||
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
|
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
|
||||||
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
|
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
|
||||||
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
||||||
|
|
||||||
|
These shortcuts are the definition; the controls panel (REQ-UI-CONTROLS-PANEL) displays the subset of them that applies to the player's current situation, and the build buttons carry the build hotkeys on their badges (REQ-UI-BUILD-COST). Neither display defines a binding of its own.
|
||||||
|
|
||||||
### Debug Draw
|
### Debug Draw
|
||||||
|
|
||||||
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
|
- REQ-UI-DEBUG-DRAW: A debug draw mode can be toggled on and off with the **F3** key. It is inactive by default. While active, the sensor range of every ship — both player and enemy — is drawn as a circle centered on the ship, using that ship schematic's outline color from `visuals.toml`.
|
||||||
@@ -522,24 +526,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 grid); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
|
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected 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).
|
||||||
@@ -547,38 +591,178 @@ 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 Grid
|
### Build Button Bar
|
||||||
|
|
||||||
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each.
|
- 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-COST: Each button caption shows the building name and its building block cost with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, e.g. `Belt: 2` then a small block icon. When no icon file exists for `building_block`, the caption falls back to the text form, e.g. "Belt: 2 Blocks".
|
- **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.
|
||||||
- REQ-UI-BUILD-ICON: Each build button shows an icon alongside its caption. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-GRID) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button with its caption and no icon; it is not an error.
|
- **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).
|
||||||
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip with the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). If a building type defines no tooltip text, its button shows no tooltip. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- REQ-UI-BUILD-ICON: Each build button shows an icon. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-BAR) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button showing its building name as a text caption in place of the icon, so the button stays identifiable in the icon-only bar (REQ-UI-BUILD-COST); it is not an error.
|
||||||
|
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip consisting of the building name followed by the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). Because the button caption is icon-only (REQ-UI-BUILD-COST), the name is always part of the tooltip; if a building type defines no tooltip text, the tooltip shows the name alone. This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
|
||||||
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
||||||
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button grid. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
||||||
|
|
||||||
### Blueprint Panel
|
### Controls Panel
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
|
The controls panel tells the player which controls are available right now. It is context-sensitive: the game is always in exactly one **control context**, derived from the active build mode and the current selection, and the panel shows that context's rows and no others. Its position, size, and overlay behavior are defined in REQ-UI-CONTROLS-PANEL; its structure in REQ-UI-CONTROLS-CARD; and which rows each context shows in REQ-UI-CONTROLS-CONTENT. The panel never defines a binding: every row restates one already defined in REQ-UI-HOTKEYS or in the mouse gestures cited beside it.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button grid) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
|
- REQ-UI-CONTROLS-PANEL: The **controls panel** is a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), anchored to the view's **bottom-left corner** with a small margin on both edges. It is **sized to its content in both width and height**, growing and shrinking upward from that corner as the context changes. Should its content ever be taller than the view, the panel's height is capped at the view height less its margins and the content scrolls vertically within it. The panel and the selection panel are on opposite sides of the view and never overlap.
|
||||||
|
- **Stepping around the build button bar.** Unlike the selection panel (REQ-UI-SELECTION-PANEL), the panel does **not** confine itself to the band above the build button bar's strip: the bar is horizontally centered and sized to its buttons (REQ-UI-BUILD-BAR), so it normally leaves this corner free, and the panel shares the view's bottom edge with it. The panel **rises only to avoid an actual overlap**: whenever the panel at the bottom-left corner would intersect the bar's current rectangle, it is moved up so that its bottom edge clears the bar's top by the same margin it keeps from the view's edges, and its height is capped at the space that leaves. Whether it rises therefore depends on how wide the bar and the panel currently are, and it returns to the corner as soon as they no longer meet. The bar never moves on the panel's account (REQ-UI-BUILD-BAR).
|
||||||
|
- **Visibility.** The panel is shown whenever the game is being played. Unlike the selection panel it has no empty state (REQ-UI-EMPTY-SELECTION): every context has rows, so there is never nothing to show.
|
||||||
|
- **Collapsing.** Clicking anywhere on the panel's header (REQ-UI-CONTROLS-CARD) toggles the panel between **expanded** and **collapsed**. Collapsed, it shows its header alone, keeping the context name visible and the header clickable so the panel can be expanded again; expanded, it shows the header followed by every row of the current context. The panel starts **expanded**. Changing context does not change the collapsed state: a panel collapsed in one context stays collapsed in the next, and its header updates in place. The collapsed state is presentation-only — it is not a player command, never enters the replay stream, and has no effect on the simulation. It persists for as long as the application runs, including across a restart from the escape menu (REQ-UI-GAME-MENU), and is not saved to disk.
|
||||||
|
- **Overlay behavior.** As for the build button bar and the selection panel (REQ-UI-BUILD-BAR, REQ-UI-SELECTION-PANEL): the panel occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected. It is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full left-hand band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the panel.
|
||||||
|
- **Input.** Mouse events over the panel are consumed by the panel and never reach the game world: hovering it shows no builder-mode ghost at the tile beneath, and clicking it neither places a building nor changes the selection. Right-clicking the panel does not exit builder mode (REQ-BLD-BUILDER-MODE) or cancel a belt drag (REQ-BLD-BELT-DRAG). The only control the panel itself offers is the header click that collapses and expands it.
|
||||||
|
- REQ-UI-CONTROLS-CARD: **Card structure.** The panel is a card with two parts, top to bottom:
|
||||||
|
- **Header** — always shown, and the panel's only interactive element (REQ-UI-CONTROLS-PANEL). It holds a colored context dot on the left, the context's name beside it in upper case, and, for contexts that define one, a **detail suffix** separated by a middle dot (`BUILD MODE · Assembler`). The name and detail per context are given in REQ-UI-CONTROLS-CONTENT.
|
||||||
|
- **Rows** — one per available control, shown only while the panel is expanded. Each row is one or more **key badges** on the left — the key or mouse button drawn as a small bordered chip — and a **label** beside them naming what it does. An action reachable two ways carries both badges in the same row (`RMB` `Q` — Exit placement) rather than occupying two rows. A row whose action leaves the current mode is drawn with the destructive badge styling, distinguishing it from the rows that act within the mode. No row is ever drawn greyed or otherwise disabled: a control the player cannot currently use is not shown at all (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint panel (REQ-UI-BLUEPRINT-PANEL), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
|
The rows that are live in every context (REQ-UI-CONTROLS-CONTENT) are shown last, under a divider and the caption `ALWAYS AVAILABLE`. This holds in every context including the General one, which has context rows of its own above the divider like any other, so the card is read the same way wherever the player is.
|
||||||
|
- REQ-UI-CONTROLS-CONTENT: **Content catalog.** The control context follows from the active build mode and the selection alone. Build modes are mutually exclusive (REQ-BLD-BUILDER-MODE), so exactly one context applies at any moment:
|
||||||
|
|
||||||
|
| Context | When | Header name | Header detail |
|
||||||
|
|---|---|---|---|
|
||||||
|
| General | no build mode active, nothing selected | `GENERAL` | — |
|
||||||
|
| Selection | no build mode active, at least one object selected | `SELECTION` | `<n> buildings` or `<n> objects` |
|
||||||
|
| Build | builder mode active (REQ-BLD-BUILDER-MODE) | `BUILD MODE` | the building type's name |
|
||||||
|
| Blueprint | blueprint placement mode active (REQ-UI-BLUEPRINT-MODE) | `BLUEPRINT MODE` | the blueprint's name, or `Temporary` for a temporary blueprint (REQ-UI-BLUEPRINT-TEMP) |
|
||||||
|
| Deconstruct | deconstruct mode active (REQ-UI-DECONSTRUCT-BUTTON) | `DECONSTRUCT MODE` | — |
|
||||||
|
|
||||||
|
The Selection context's detail counts the selection and names its category (REQ-UI-SELECTION-CATEGORIES): `<n> buildings` for a building selection, `<n> objects` for a field selection, in the singular at a count of one. The Build and Blueprint contexts show **the same rows** and differ only in their header.
|
||||||
|
|
||||||
|
**Always-available rows**, shown in every context:
|
||||||
|
|
||||||
|
| Badges | Label | Shown |
|
||||||
|
|---|---|---|
|
||||||
|
| `A` `D` | Move | always |
|
||||||
|
| `W` `S` | Game speed | always |
|
||||||
|
| `Space` | Toggle pause | always |
|
||||||
|
| `V` | Paste last | only while a temporary blueprint exists (REQ-UI-BLUEPRINT-TEMP) |
|
||||||
|
| `Ctrl` `V` | Blueprints | always |
|
||||||
|
| `Esc` | Menu | always |
|
||||||
|
|
||||||
|
**Context rows**, shown above the always-available block:
|
||||||
|
|
||||||
|
| Context | Badges | Label |
|
||||||
|
|---|---|---|
|
||||||
|
| General | `LMB` | Select |
|
||||||
|
| | `LMB` drag | Select area |
|
||||||
|
| | `Q` | Deconstruct mode |
|
||||||
|
| Selection | `LMB` | Select / clear selection |
|
||||||
|
| | `LMB` drag | Select area |
|
||||||
|
| | `Ctrl` `LMB` | Add / remove from selection |
|
||||||
|
| | `Ctrl` `LMB` drag | Add area to selection |
|
||||||
|
| | `Q` | Deconstruct mode |
|
||||||
|
| | `C` | Copy to temporary blueprint |
|
||||||
|
| | `Ctrl` `C` | Create blueprint |
|
||||||
|
| Build, Blueprint | `LMB` | Place |
|
||||||
|
| | `LMB` drag | Place belt line |
|
||||||
|
| | `R` / `Shift` `R` | Rotate |
|
||||||
|
| | `RMB` `Q` | Exit placement |
|
||||||
|
| Deconstruct | `LMB` | Toggle deconstruct |
|
||||||
|
| | `LMB` drag | Deconstruct area |
|
||||||
|
| | `RMB` `Q` | Exit deconstruct mode |
|
||||||
|
|
||||||
|
Four rows are conditional on more than the context, because the binding behind them is:
|
||||||
|
- **`C` / `Ctrl` `C`** are shown only while the selection holds at least one player-placeable building, the condition under which those keys do anything (REQ-UI-HOTKEYS). A selection of ships, defence stations, or debris shows neither.
|
||||||
|
- **`LMB` drag — Place belt line** is shown only in builder mode for the Belt type, the only type placed by dragging (REQ-BLD-BELT-DRAG). Every other builder type and blueprint placement omit the row.
|
||||||
|
- **`RMB` `Q` — Exit placement** splits into two rows **while a belt drag is in progress**, because the two bindings then part company (REQ-BLD-BELT-DRAG): `RMB` reads **Cancel belt line** and cancels the drag while leaving builder mode active, and `Q` reads **Exit placement** and leaves the mode outright.
|
||||||
|
- **`LMB` — Place** reads **Apply settings** instead whenever the ghost under the cursor resolves to a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) — a single-building blueprint hovering a same-type building of a configurable type, which is the case in which clicking hands over settings rather than placing anything. A blueprint holding more than one building keeps the `Place` label, since its click both places and transfers (REQ-UI-BLUEPRINT-PLACE).
|
||||||
|
- REQ-UI-CONTROLS-ACCURACY: **The panel never advertises a binding that would do nothing.** Every row shown must, if triggered in the situation the panel is showing it in, have the effect its label names; a binding that is inert in the current context is omitted rather than shown greyed (REQ-UI-CONTROLS-CARD). The relation holds in one direction only: the panel may omit a binding that is available, and deliberately does so in three cases:
|
||||||
|
- **Build hotkeys** (REQ-UI-HOTKEYS) are live in every context, but are advertised on the build buttons' badges (REQ-UI-BUILD-COST) instead of taking eleven rows in every context of this panel.
|
||||||
|
- **`C` and `Ctrl` `C`** are omitted from the Build, Blueprint, and Deconstruct contexts even though a selection surviving into a build mode keeps them working. They belong to the Selection context, and repeating them in every mode would defeat the panel's purpose of showing what the player's current situation affords.
|
||||||
|
- **`Ctrl` `LMB` and `Ctrl` `LMB` drag** work with nothing selected — they select the object under the cursor much as a plain click would — but are shown only in the Selection context. "Add / remove from selection" names an operation on a selection, and there is none to operate on until something is selected; the plain `LMB` row already covers what the gesture does before then.
|
||||||
|
- **`F3` and `F4`** (REQ-UI-DEBUG-DRAW) are development controls rather than player controls and appear in no context.
|
||||||
|
|
||||||
|
This asymmetry between what is available and what is shown is why the two are separate questions in the implementation, and why the tests assert that a resolvable input is *available* rather than that it is displayed.
|
||||||
|
|
||||||
|
### Blueprints
|
||||||
|
|
||||||
|
Blueprints 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-DIALOG: The **blueprint selection dialog** is the only place saved blueprints are shown. It is opened by pressing **Ctrl+V** (REQ-UI-HOTKEYS) and by confirming a save (REQ-UI-BLUEPRINT-CREATE). It is modal, pauses the simulation, and dims the game window (REQ-UI-MODAL-DIM). The dialog has a fixed size and consists of:
|
||||||
|
- A **title bar** reading `Blueprints`, followed by a small dimmed **hotkey badge** reading `Ctrl+V` — the same "learn the shortcut from the widget" device as the build button badges (REQ-UI-BUILD-COST) — and, at the far right, a **close ("×") button**.
|
||||||
|
- Below it, a **scrollable two-column grid of blueprint cards** (REQ-UI-BLUEPRINT-CARD), one per saved blueprint, filling the grid left to right and top to bottom in creation order. The column count is fixed at two; the grid scrolls vertically when the cards do not fit, and does not scroll horizontally.
|
||||||
|
- When no blueprints are saved, the dialog still opens and shows an empty-state message in place of the grid, telling the player that blueprints are created with Ctrl+C from a selection of buildings.
|
||||||
|
|
||||||
|
Clicking the close button, pressing Escape, or closing the dialog through the window manager closes it with no other effect: the current selection, build mode, and blueprint list are unchanged, and the simulation speed is restored to what it was before the dialog was opened. While the dialog is open, Escape closes it rather than opening the escape menu (REQ-UI-GAME-MENU).
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------------------+
|
||||||
|
| Blueprints [Ctrl+V] [x] |
|
||||||
|
+------------------------------------------------------+
|
||||||
|
| +---------------------+ +---------------------+ |^| |
|
||||||
|
| | Smelter array | | Gear cell | | | |
|
||||||
|
| | 4 Smelters, 1 Miner | | 2 Assemblers, ... | | | |
|
||||||
|
| | 528 [blk] (x) | | 460 [blk] (x) | | | |
|
||||||
|
| +---------------------+ +---------------------+ | | |
|
||||||
|
| +---------------------+ | | |
|
||||||
|
| | Defence line | |_v_|
|
||||||
|
+------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-TEMP: Pressing the **C** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing C with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing at all, and in particular leaves any existing temporary blueprint in place. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode; unlike the mode, the temporary blueprint itself survives, so it can be entered again with V.
|
||||||
|
|
||||||
|
Pressing the **V** key re-enters blueprint placement mode for the temporary blueprint, capturing nothing new: it is independent of the current selection, can be pressed any number of times, and yields exactly the mode described above. Like C it replaces any currently active build, blueprint placement, or deconstruct mode, and it does not test whether the player can currently afford the blueprint — cost is enforced at placement (REQ-UI-BLUEPRINT-PLACE), consistently with C. Pressing V when no temporary blueprint exists does nothing: no mode is entered and any currently active mode is left untouched.
|
||||||
|
|
||||||
|
There is at most one temporary blueprint at a time; pressing C replaces the previous one. It is held only in memory for the current run: it is discarded when the application closes and when the simulation is restarted from the escape menu (REQ-UI-GAME-MENU), after which V does nothing until C captures a new one.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. A source building may be either an operational building or a construction site (REQ-BLD-SITE-CONFIG); a construction site is captured identically, storing whatever configuration it currently holds and never any buffer or construction-progress state. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
|
- REQ-UI-BLUEPRINT-STORAGE: A blueprint stores its name and, for each building in the selection, the building type, its rotation, its tile offset (integer dx, dy) from the center of the bounding box of all selected buildings' footprints, and — where applicable — the selected recipe ID (miners and assemblers) or schematic ID (shipyards), and for splitters the two output filters (each a list of item types; an empty list means accept-all), at the time of capture. A source building may be either an operational building or a construction site (REQ-BLD-SITE-CONFIG); a construction site is captured identically, storing whatever configuration it currently holds and never any buffer or construction-progress state. If no recipe or schematic was selected at capture time, none is stored; for a splitter with no filters set, no filter lists are stored. This structure maps directly to a TOML representation (e.g. one `[[building]]` array entry per constituent building, with the splitter filters as `filter_a`/`filter_b` arrays of item-type ids).
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-BUTTON: Each blueprint entry consists of a blueprint button and a dedicated delete icon ("×") placed to the right of the button. The blueprint button displays the blueprint name and, below it, the total building block cost of the blueprint (sum of the individual costs of all constituent buildings). A blueprint button is disabled when the player cannot afford the total cost. Clicking an enabled blueprint button enters blueprint placement mode for that blueprint. The delete icon is always enabled regardless of whether the player can afford the blueprint.
|
- REQ-UI-BLUEPRINT-CARD: Each blueprint in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) is shown as a **card**. All cards share a uniform size. A card contains, from top to bottom:
|
||||||
|
- The **blueprint name**.
|
||||||
|
- A **contents line** summarizing what the blueprint holds: one `<building name> x <count>` entry per building type it contains, comma-separated, in descending count order with ties broken by the order the types appear in the build button bar (REQ-UI-BUILD-BAR). This is the same `x`-count notation as the building multi-selection summary (REQ-UI-MULTI-SELECTION). If the entries do not fit on the line, the line is elided at its end rather than wrapped or shrunk, so every card keeps the same height.
|
||||||
|
- The **total building block cost** of the blueprint (sum of the individual costs of all constituent buildings), shown with the `building_block` item icon to the right of the number in place of a trailing `Blocks` word, exactly as elsewhere in the UI (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST). When no icon file exists for `building_block`, the cost is shown as the bare number.
|
||||||
|
- A **delete icon ("×")** in the card's bottom-right corner (REQ-UI-BLUEPRINT-DELETE).
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint (excluding any of a currently locked building type, REQ-LOCK-BUILDING, which is omitted entirely per REQ-LOCK-UI-BLUEPRINT) at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). A valid ghost uses its building type's semi-transparent per-building coloring (REQ-BLD-GHOST); an invalid ghost uses the distinct "invalid" color, as in single-building builder mode. Pressing R / Shift+R rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Clicking a different blueprint button exits the current mode and enters blueprint placement mode for the newly clicked blueprint.
|
A card shows no preview of the blueprint's layout.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-PLACE: Buildings of a currently locked building type (REQ-LOCK-BUILDING) are first excluded from the blueprint for this placement, per REQ-LOCK-UI-BLUEPRINT — they are not ghosted, not validity-checked, not placed, and their cost is excluded from the total. Left-clicking in blueprint placement mode then places the (remaining) blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. If both conditions are met, a construction site is added to the build queue for each building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. If splitter output filters are stored, they are applied to the construction site immediately and carry over when it finishes building (REQ-BLD-SITE-CONFIG). Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
|
Clicking anywhere on an enabled card other than its delete icon closes the dialog and enters blueprint placement mode for that blueprint (REQ-UI-BLUEPRINT-MODE). A card is disabled when the player cannot currently afford its total cost; a disabled card is rendered dimmed and clicking it does nothing (consistent with REQ-UI-BUILD-DISABLED), and neither closes the dialog nor enters placement mode. The delete icon is always enabled regardless of whether the player can afford the blueprint.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint entry immediately removes that blueprint from the list. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
|
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint (excluding any of a currently locked building type, REQ-LOCK-BUILDING, which is omitted entirely per REQ-LOCK-UI-BLUEPRINT) at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). A valid ghost uses its building type's semi-transparent per-building coloring (REQ-BLD-GHOST); an invalid ghost uses the distinct "invalid" color, as in single-building builder mode; a ghost over a configuration-transfer target uses the distinct "transfer" color instead of either and, for a single-building blueprint, is drawn snapped onto the target rather than at the blueprint's own position (REQ-UI-BLUEPRINT-TRANSFER); a ghost over a compatible overlap counts as valid and keeps the ordinary per-building coloring (REQ-UI-BLUEPRINT-OVERLAP). Pressing R / Shift+R rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Opening the blueprint selection dialog while placement mode is active (REQ-UI-BLUEPRINT-DIALOG) leaves the mode active — closing the dialog without picking a card returns to it unchanged — while clicking a card exits the current mode and enters blueprint placement mode for the newly picked blueprint.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-PLACE: This describes placing the blueprint's buildings as new construction sites. Ghosts sitting on a building that is already there are handled elsewhere and place nothing: a configuration-transfer target receives the blueprint's stored settings instead (REQ-UI-BLUEPRINT-TRANSFER), and a compatible overlap is left alone (REQ-UI-BLUEPRINT-OVERLAP). Both still take part in the single all-or-nothing click described here. Buildings of a currently locked building type (REQ-LOCK-BUILDING) are first excluded from the blueprint for this placement, per REQ-LOCK-UI-BLUEPRINT — they are not ghosted, not validity-checked, not placed, and their cost is excluded from the total. Left-clicking in blueprint placement mode then places the (remaining) blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. Buildings that are compatible overlaps (REQ-UI-BLUEPRINT-OVERLAP) or configuration-transfer targets (REQ-UI-BLUEPRINT-TRANSFER) are excluded from the total cost and are not placed, but do not block the placement; a transfer target additionally receives the blueprint's stored settings. If both conditions are met, a construction site is added to the build queue for each remaining building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. If splitter output filters are stored, they are applied to the construction site immediately and carry over when it finishes building (REQ-BLD-SITE-CONFIG). Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-OVERLAP: **Compatible overlap.** In blueprint placement mode, a ghost whose footprint **exactly coincides** with the footprint of an existing placed building or construction site that is of the **same building type** and has the **same rotation** is a *compatible overlap*: the building the blueprint wants is already there. Such a ghost is **valid** despite the occupied tiles (REQ-BLD-PLACE-VALID condition (b)), so it does not block the placement of the rest of the constellation — dropping a blueprint over a partially-built copy of itself fills in what is missing. It is drawn in the ordinary per-building ghost color (REQ-BLD-GHOST), like any other valid ghost.
|
||||||
|
|
||||||
|
On placement the overlapped building is **left completely untouched**: no construction site is placed on it, no building blocks are charged for it (it is excluded from the total cost of REQ-UI-BLUEPRINT-PLACE), its rotation is not changed, and a construction site's progress is preserved. This applies per building in the blueprint, independently, and to blueprints of any size. Unlike REQ-BLD-ROTATE-IN-PLACE, which it replaces in this mode, it applies to Tunnel Entries and Tunnel Exits too — nothing is re-oriented, so the reason for their exception does not arise.
|
||||||
|
|
||||||
|
A coinciding same-type building whose **rotation differs** is not a compatible overlap: the blueprint cannot rotate it (REQ-BLD-ROTATE-IN-PLACE no longer applies here), so the position is an ordinary occupied-tile overlap and therefore invalid.
|
||||||
|
|
||||||
|
**Order of the two rules.** A ghost is tested for a configuration transfer (REQ-UI-BLUEPRINT-TRANSFER) first, and this requirement governs only what that test does not claim. Because a coinciding building of a **configurable** type always transfers, this requirement covers exactly the types that have nothing to configure:
|
||||||
|
- **Configurable type** (Miner, Assembler, Shipyard, Splitter) — a transfer, never a compatible overlap. It is drawn in the transfer color and hands the blueprint's settings over.
|
||||||
|
- **Type with no settings** (Smelter, Reprocessing Plant, Salvage Bay, belt, tunnel end) — a compatible overlap if the rotation matches, invalid otherwise. There is nothing to hand over, so the building is simply left as it is and the ghost keeps its ordinary color. This holds for a single-building blueprint too: the cursor hit-test of REQ-UI-BLUEPRINT-TRANSFER applies only to configurable types, so hovering a belt with a belt blueprint still just overlaps it.
|
||||||
|
|
||||||
|
A single blueprint can hold both kinds at once, and each ghost is judged on its own: dropping a constellation over a partial copy of itself may reconfigure some of the buildings already there (cyan) while leaving others untouched (ordinary color) and placing the rest as new construction sites.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-TRANSFER: **Configuration transfer.** A blueprint hands its stored settings to buildings that are already standing where it wants them, instead of only to ones it places. A single-building blueprint therefore doubles as a way to copy one building's settings onto others of the same type: select a configured building, press **C** to capture it as a temporary blueprint and enter placement mode (REQ-UI-BLUEPRINT-TEMP), then click same-type buildings to stamp its settings onto them. **V** re-enters that mode later. A multi-building blueprint does the same for each of its buildings as it is placed.
|
||||||
|
|
||||||
|
A blueprint ghost is a **configuration-transfer target** when all of the following hold:
|
||||||
|
- The building is of a **configurable building type** — one with player-facing settings: Miner and Assembler (recipe), Shipyard (schematic and module layout), Splitter (output filters). Whether anything was actually configured at capture time is irrelevant; an unconfigured source transfers its unconfigured state (see below). Building types with no settings at all (Smelter, Reprocessing Plant, Salvage Bay, belts, tunnel entries and exits, the HQ) never transfer; a coinciding building of those types is a compatible overlap instead (REQ-UI-BLUEPRINT-OVERLAP).
|
||||||
|
- There is a target, found in one of two ways depending on the blueprint's size, because the two are different gestures:
|
||||||
|
- **Single-building blueprint** — the target is the building or construction site **under the cursor**, if it is of the same type. There is no coincidence test at all: the target's rotation, its footprint, and its alignment with the ghost are all irrelevant. The ghost is drawn **snapped onto the target**, at the target's own anchor and rotation, so it shows what the click will act on rather than where a building would go. This is the copying gesture, and a footprint test made it unusable for buildings that are not square: a Shipyard rotated 90° covers different tiles altogether, so no amount of lining up would ever match a differently-facing one.
|
||||||
|
- **Multi-building blueprint** — the ghost's footprint must **exactly coincide** with the footprint of an existing building or site of the same type (the coincidence test of REQ-BLD-ROTATE-IN-PLACE), and that target must have the **same rotation** as the ghost, which is what makes the position valid at all (REQ-UI-BLUEPRINT-OVERLAP). A constellation is placed as a layout, so its ghosts stay where the blueprint puts them: nothing snaps to the cursor and nothing is re-oriented.
|
||||||
|
|
||||||
|
At a transfer target:
|
||||||
|
- The ghost is drawn in a distinct **transfer** color read from `visuals.toml [overlays]`, overriding both the per-building coloring and the "invalid" color (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID). The position counts as valid despite the occupied tiles (REQ-BLD-PLACE-VALID condition (b)).
|
||||||
|
- **Left-clicking transfers the configuration** to the existing building or site, making the target's settings **identical to the source's**: the recipe ID (Miner, Assembler), the schematic ID together with the ship layout (Shipyard), or the two output filters (Splitter). No construction site is placed, no building blocks are consumed (it is excluded from the total cost of REQ-UI-BLUEPRINT-PLACE), and the target's **rotation is not changed** — a transfer never rotates.
|
||||||
|
- The transfer is a **full mirror, including the absence of a setting**: where the blueprint stores no configuration for a field (REQ-UI-BLUEPRINT-STORAGE stores nothing for an unselected recipe or schematic, and no filter lists for a splitter whose filters were empty at capture time), the target's corresponding setting is **cleared** rather than left as it was. So a splitter captured with no filters clears the target splitter's filters back to accept-all, and a miner captured with no recipe selected clears the target miner's recipe. This holds for every blueprint size, so a constellation captured from unconfigured buildings clears the settings of every matching building it is dropped on.
|
||||||
|
- The transfer has the same effects as making that selection through the 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.
|
||||||
|
- Both operational buildings and construction sites are transfer targets (REQ-BLD-SITE-CONFIG); a configuration applied to a site carries over unchanged when it finishes building.
|
||||||
|
- After the transfer the game stays in blueprint placement mode, so further same-type buildings can be clicked in turn.
|
||||||
|
- A blueprint placement applies every transfer among its ghosts in the same click that places its new construction sites (REQ-UI-BLUEPRINT-PLACE); the placement is all-or-nothing, so if any ghost is invalid nothing is placed and nothing is transferred.
|
||||||
|
|
||||||
|
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint card (REQ-UI-BLUEPRINT-CARD) immediately removes that blueprint from the list, without a confirmation prompt. The blueprint selection dialog stays open and its card grid reflows to close the gap. If the deleted blueprint was active in blueprint placement mode, that mode is exited.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).
|
- REQ-UI-BLUEPRINT-SAVE: On application shutdown, all current blueprints are serialized to a file named `blueprints.toml` located in the same directory as the application executable. The TOML structure matches REQ-UI-BLUEPRINT-STORAGE. Write errors are silently ignored on shutdown (no button, no dialog).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
set(TARGET_BASE_NAME "${PRODUCT_NAME}")
|
set(TARGET_BASE_NAME "${PRODUCT_NAME}")
|
||||||
|
|
||||||
set(TARGET_APP_NAME "${TARGET_BASE_NAME}")
|
set(TARGET_APP_NAME "${TARGET_BASE_NAME}")
|
||||||
@@ -13,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")
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ unset(SRCS)
|
|||||||
|
|
||||||
set(HDRS)
|
set(HDRS)
|
||||||
set(SRCS)
|
set(SRCS)
|
||||||
|
set(UI_INCLUDE_PATH)
|
||||||
|
|
||||||
add_subdirectory(ui)
|
add_subdirectory(ui)
|
||||||
|
|
||||||
@@ -109,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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
#include "DebrisComponent.h"
|
#include "DebrisComponent.h"
|
||||||
|
#include "WorldPrimitives.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -176,55 +177,36 @@ void ArenaView::paintGL()
|
|||||||
QPainter painter(this);
|
QPainter painter(this);
|
||||||
painter.setRenderHint(QPainter::Antialiasing, false);
|
painter.setRenderHint(QPainter::Antialiasing, false);
|
||||||
|
|
||||||
drawTiles(painter);
|
// One transform snapshot for the whole frame; every draw below reads the
|
||||||
drawBuildings(painter);
|
// viewport through it.
|
||||||
drawStations(painter);
|
const WorldCoordinates coordinates = getCoordinates();
|
||||||
drawDebris(painter);
|
|
||||||
|
drawTiles(painter, coordinates);
|
||||||
|
drawBuildings(painter, coordinates);
|
||||||
|
drawStations(painter, coordinates);
|
||||||
|
drawDebris(painter, coordinates);
|
||||||
if (m_debugDraw)
|
if (m_debugDraw)
|
||||||
{
|
{
|
||||||
drawDebugSensorRanges(painter);
|
drawDebugSensorRanges(painter, coordinates);
|
||||||
drawDebugTargetLines(painter);
|
drawDebugTargetLines(painter, coordinates);
|
||||||
}
|
}
|
||||||
drawShips(painter);
|
drawShips(painter, coordinates);
|
||||||
drawBeams(painter);
|
drawBeams(painter, coordinates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Coordinate helpers
|
// Coordinate helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
float ArenaView::getTilePx() const
|
WorldCoordinates ArenaView::getCoordinates() const
|
||||||
{
|
{
|
||||||
|
// The arena is a fixed, fully visible world — unlike the game view it has no
|
||||||
|
// scrolling, so the tile size comes from fitting the whole arena in the widget.
|
||||||
const ArenaConfig& ac = m_sim->getArenaConfig();
|
const ArenaConfig& ac = m_sim->getArenaConfig();
|
||||||
const int totalWidth = ac.playerBufferWidth_tiles
|
const int totalWidth = ac.playerBufferWidth_tiles
|
||||||
+ ac.contestZoneWidth_tiles
|
+ ac.contestZoneWidth_tiles
|
||||||
+ ac.enemyBufferWidth_tiles;
|
+ ac.enemyBufferWidth_tiles;
|
||||||
const int totalHeight = ac.heightTiles;
|
return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles);
|
||||||
if (totalWidth <= 0 || totalHeight <= 0) { return 1.0f; }
|
|
||||||
|
|
||||||
const float pxPerTileH = static_cast<float>(height()) / static_cast<float>(totalHeight);
|
|
||||||
const float pxPerTileW = static_cast<float>(width()) / static_cast<float>(totalWidth);
|
|
||||||
return std::min(pxPerTileH, pxPerTileW);
|
|
||||||
}
|
|
||||||
|
|
||||||
QPointF ArenaView::worldToWidget(QVector2D worldPos) const
|
|
||||||
{
|
|
||||||
return QPointF(
|
|
||||||
static_cast<qreal>(worldPos.x() * getTilePx()),
|
|
||||||
static_cast<qreal>(worldPos.y() * getTilePx()));
|
|
||||||
}
|
|
||||||
|
|
||||||
QPointF ArenaView::tileToWidget(QPoint tile) const
|
|
||||||
{
|
|
||||||
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
|
|
||||||
static_cast<float>(tile.y())));
|
|
||||||
}
|
|
||||||
|
|
||||||
QRectF ArenaView::tileRect(QPoint tile) const
|
|
||||||
{
|
|
||||||
const QPointF tl = tileToWidget(tile);
|
|
||||||
return QRectF(tl.x(), tl.y(),
|
|
||||||
static_cast<qreal>(getTilePx()), static_cast<qreal>(getTilePx()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
||||||
@@ -236,19 +218,11 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
|||||||
return m_sim->getAdmin().get<PositionComponent>(entity).value;
|
return m_sim->getAdmin().get<PositionComponent>(entity).value;
|
||||||
}
|
}
|
||||||
|
|
||||||
QVector2D ArenaView::widgetToWorld(QPoint widgetPt) const
|
|
||||||
{
|
|
||||||
const float px = getTilePx();
|
|
||||||
if (px < 0.001f) { return QVector2D(0.0f, 0.0f); }
|
|
||||||
return QVector2D(static_cast<float>(widgetPt.x()) / px,
|
|
||||||
static_cast<float>(widgetPt.y()) / px);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ArenaView::mousePressEvent(QMouseEvent* event)
|
void ArenaView::mousePressEvent(QMouseEvent* event)
|
||||||
{
|
{
|
||||||
if (event->button() == Qt::LeftButton)
|
if (event->button() == Qt::LeftButton)
|
||||||
{
|
{
|
||||||
const QVector2D worldPos = widgetToWorld(event->pos());
|
const QVector2D worldPos = getCoordinates().widgetToWorld(event->pos());
|
||||||
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
||||||
|
|
||||||
if (hit != entt::null)
|
if (hit != entt::null)
|
||||||
@@ -287,7 +261,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
|
|||||||
// Rendering
|
// Rendering
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void ArenaView::drawTiles(QPainter& painter)
|
void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
const ArenaConfig& ac = m_sim->getArenaConfig();
|
const ArenaConfig& ac = m_sim->getArenaConfig();
|
||||||
const int totalWidth = ac.playerBufferWidth_tiles
|
const int totalWidth = ac.playerBufferWidth_tiles
|
||||||
@@ -300,12 +274,12 @@ void ArenaView::drawTiles(QPainter& painter)
|
|||||||
{
|
{
|
||||||
for (int y = 0; y < totalHeight; ++y)
|
for (int y = 0; y < totalHeight; ++y)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(QPoint(x, y)), m_visuals->space.fill);
|
painter.fillRect(coordinates.tileRect(QPoint(x, y)), m_visuals->space.fill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawBuildings(QPainter& painter)
|
void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||||
{
|
{
|
||||||
@@ -317,13 +291,13 @@ void ArenaView::drawBuildings(QPainter& painter)
|
|||||||
painter.setPen(Qt::NoPen);
|
painter.setPen(Qt::NoPen);
|
||||||
for (const QPoint& cell : b.bodyCells)
|
for (const QPoint& cell : b.bodyCells)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(cell), bv.fill);
|
painter.fillRect(coordinates.tileRect(cell), bv.fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QPointF tl = tileToWidget(b.anchor);
|
const QPointF tl = coordinates.tileToWidget(b.anchor);
|
||||||
const QRectF bboxRect(tl.x(), tl.y(),
|
const QRectF bboxRect(tl.x(), tl.y(),
|
||||||
b.footprint.width() * static_cast<qreal>(getTilePx()),
|
b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||||
b.footprint.height() * static_cast<qreal>(getTilePx()));
|
b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||||
|
|
||||||
painter.setPen(QPen(bv.outline, 1));
|
painter.setPen(QPen(bv.outline, 1));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
@@ -337,20 +311,16 @@ void ArenaView::drawBuildings(QPainter& painter)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebris(QPainter& painter)
|
void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
const float r = getTilePx() * 0.2f;
|
|
||||||
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
|
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
|
||||||
{
|
{
|
||||||
const QPointF center = worldToWidget(debris.position);
|
drawDebrisMarker(painter, coordinates,
|
||||||
painter.setBrush(QColor(128, 110, 90));
|
coordinates.worldToWidget(debris.position));
|
||||||
painter.setPen(QPen(QColor(50, 40, 30), 1));
|
|
||||||
painter.drawEllipse(center,
|
|
||||||
static_cast<qreal>(r), static_cast<qreal>(r));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawStations(QPainter& painter)
|
void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||||
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
|
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
|
||||||
@@ -366,13 +336,13 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
painter.setPen(Qt::NoPen);
|
painter.setPen(Qt::NoPen);
|
||||||
for (const QPoint& cell : sb.bodyCells)
|
for (const QPoint& cell : sb.bodyCells)
|
||||||
{
|
{
|
||||||
painter.fillRect(tileRect(cell), bv.fill);
|
painter.fillRect(coordinates.tileRect(cell), bv.fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
const QPointF tl = tileToWidget(sb.anchor);
|
const QPointF tl = coordinates.tileToWidget(sb.anchor);
|
||||||
const QRectF bboxRect(tl.x(), tl.y(),
|
const QRectF bboxRect(tl.x(), tl.y(),
|
||||||
sb.footprint.width() * static_cast<qreal>(getTilePx()),
|
sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||||
sb.footprint.height() * static_cast<qreal>(getTilePx()));
|
sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||||
|
|
||||||
painter.setPen(QPen(bv.outline, 1));
|
painter.setPen(QPen(bv.outline, 1));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
@@ -380,14 +350,9 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
|
|
||||||
if (h.maxHp > 0.0f)
|
if (h.maxHp > 0.0f)
|
||||||
{
|
{
|
||||||
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
drawHealthBar(painter, coordinates, bboxRect.left(),
|
||||||
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
|
bboxRect.bottom() + 1.0, bboxRect.width(),
|
||||||
const qreal barY = bboxRect.bottom() + 1.0;
|
h.hp / h.maxHp, f.isEnemy);
|
||||||
const qreal barW = bboxRect.width();
|
|
||||||
painter.fillRect(QRectF(bboxRect.left(), barY, barW, barH),
|
|
||||||
QColor(60, 60, 60));
|
|
||||||
painter.fillRect(QRectF(bboxRect.left(), barY, barW * static_cast<qreal>(fraction), barH),
|
|
||||||
f.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||||||
@@ -399,8 +364,10 @@ void ArenaView::drawStations(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawShips(QPainter& painter)
|
void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
|
const float forward = getShipForwardExtentPx(coordinates);
|
||||||
|
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
||||||
FactionComponent, HealthComponent>(
|
FactionComponent, HealthComponent>(
|
||||||
[&](entt::entity e, const ShipIdentityComponent& si,
|
[&](entt::entity e, const ShipIdentityComponent& si,
|
||||||
@@ -411,40 +378,22 @@ void ArenaView::drawShips(QPainter& painter)
|
|||||||
m_visuals->ships.find(si.schematicId);
|
m_visuals->ships.find(si.schematicId);
|
||||||
if (it == m_visuals->ships.end()) { return; }
|
if (it == m_visuals->ships.end()) { return; }
|
||||||
|
|
||||||
const QPointF center = worldToWidget(pos.value);
|
const QPointF center = coordinates.worldToWidget(pos.value);
|
||||||
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
|
drawShipBody(painter, coordinates, center, facing.radians,
|
||||||
const QVector2D perp(-dir.y(), dir.x());
|
it->second.fill, it->second.outline);
|
||||||
|
|
||||||
const float fwd = getTilePx() * 0.45f;
|
|
||||||
const float side = getTilePx() * 0.25f;
|
|
||||||
|
|
||||||
QPolygonF tri;
|
|
||||||
tri << QPointF(center.x() + static_cast<qreal>(dir.x() * fwd),
|
|
||||||
center.y() + static_cast<qreal>(dir.y() * fwd))
|
|
||||||
<< QPointF(center.x() + static_cast<qreal>(perp.x() * side - dir.x() * side),
|
|
||||||
center.y() + static_cast<qreal>(perp.y() * side - dir.y() * side))
|
|
||||||
<< QPointF(center.x() + static_cast<qreal>(-perp.x() * side - dir.x() * side),
|
|
||||||
center.y() + static_cast<qreal>(-perp.y() * side - dir.y() * side));
|
|
||||||
|
|
||||||
painter.setPen(QPen(it->second.outline, 1));
|
|
||||||
painter.setBrush(it->second.fill);
|
|
||||||
painter.drawPolygon(tri);
|
|
||||||
|
|
||||||
if (h.maxHp > 0.0f)
|
if (h.maxHp > 0.0f)
|
||||||
{
|
{
|
||||||
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
const qreal barW = static_cast<qreal>(forward) * 2.0;
|
||||||
const qreal barW = static_cast<qreal>(fwd) * 2.0;
|
const qreal barX = center.x() - static_cast<qreal>(forward);
|
||||||
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
|
const qreal barY = center.y() + static_cast<qreal>(forward) + 1.0;
|
||||||
const qreal barX = center.x() - static_cast<qreal>(fwd);
|
drawHealthBar(painter, coordinates, barX, barY, barW,
|
||||||
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
|
h.hp / h.maxHp, fac.isEnemy);
|
||||||
painter.fillRect(QRectF(barX, barY, barW, barH), QColor(60, 60, 60));
|
|
||||||
painter.fillRect(QRectF(barX, barY, barW * static_cast<qreal>(fraction), barH),
|
|
||||||
fac.isEnemy ? QColor(200, 60, 60) : QColor(60, 200, 60));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
if (m_selectedEntity.has_value() && *m_selectedEntity == e)
|
||||||
{
|
{
|
||||||
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55;
|
const qreal radius = static_cast<qreal>(coordinates.getTilePx()) * 0.55;
|
||||||
painter.setPen(QPen(QColor(255, 255, 0), 2));
|
painter.setPen(QPen(QColor(255, 255, 0), 2));
|
||||||
painter.setBrush(Qt::NoBrush);
|
painter.setBrush(Qt::NoBrush);
|
||||||
painter.drawEllipse(center, radius, radius);
|
painter.drawEllipse(center, radius, radius);
|
||||||
@@ -452,9 +401,9 @@ void ArenaView::drawShips(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebugSensorRanges(QPainter& painter)
|
void ArenaView::drawDebugSensorRanges(QPainter& painter,
|
||||||
|
const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
painter.setBrush(Qt::NoBrush);
|
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
|
||||||
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
||||||
const PositionComponent& pos, const SensorRangeComponent& sensor)
|
const PositionComponent& pos, const SensorRangeComponent& sensor)
|
||||||
@@ -463,17 +412,14 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter)
|
|||||||
m_visuals->ships.find(si.schematicId);
|
m_visuals->ships.find(si.schematicId);
|
||||||
if (it == m_visuals->ships.end()) { return; }
|
if (it == m_visuals->ships.end()) { return; }
|
||||||
|
|
||||||
const QPointF center = worldToWidget(pos.value);
|
drawSensorRange(painter, coordinates,
|
||||||
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
|
coordinates.worldToWidget(pos.value),
|
||||||
* static_cast<qreal>(getTilePx());
|
sensor.value_tiles, it->second.outline);
|
||||||
QColor circleColor = it->second.outline;
|
|
||||||
circleColor.setAlpha(77);
|
|
||||||
painter.setPen(QPen(circleColor, 1));
|
|
||||||
painter.drawEllipse(center, radiusPx, radiusPx);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawDebugTargetLines(QPainter& painter)
|
void ArenaView::drawDebugTargetLines(QPainter& painter,
|
||||||
|
const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
// Draw a thin translucent line from a ship to a target, colored by the ship's
|
// Draw a thin translucent line from a ship to a target, colored by the ship's
|
||||||
// team to match the per-side HQ/station colors used elsewhere in the arena
|
// team to match the per-side HQ/station colors used elsewhere in the arena
|
||||||
@@ -491,7 +437,8 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
|
|||||||
QColor lineColor = it->second.fill;
|
QColor lineColor = it->second.fill;
|
||||||
lineColor.setAlpha(128);
|
lineColor.setAlpha(128);
|
||||||
painter.setPen(QPen(lineColor, 1));
|
painter.setPen(QPen(lineColor, 1));
|
||||||
painter.drawLine(worldToWidget(from), worldToWidget(to));
|
painter.drawLine(coordinates.worldToWidget(from),
|
||||||
|
coordinates.worldToWidget(to));
|
||||||
};
|
};
|
||||||
|
|
||||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
|
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
|
||||||
@@ -536,7 +483,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void ArenaView::drawBeams(QPainter& painter)
|
void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates)
|
||||||
{
|
{
|
||||||
for (const ActiveBeam& beam : m_activeBeams)
|
for (const ActiveBeam& beam : m_activeBeams)
|
||||||
{
|
{
|
||||||
@@ -552,7 +499,7 @@ void ArenaView::drawBeams(QPainter& painter)
|
|||||||
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
||||||
}
|
}
|
||||||
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
||||||
painter.drawLine(worldToWidget(*shooterPos),
|
painter.drawLine(coordinates.worldToWidget(*shooterPos),
|
||||||
worldToWidget(*targetPos + beam.targetOffset));
|
coordinates.worldToWidget(*targetPos + beam.targetOffset));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "TickDriver.h"
|
#include "TickDriver.h"
|
||||||
#include "VisualsConfig.h"
|
#include "VisualsConfig.h"
|
||||||
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
class ArenaSimulation;
|
class ArenaSimulation;
|
||||||
class QPainter;
|
class QPainter;
|
||||||
@@ -47,22 +48,22 @@ private slots:
|
|||||||
private:
|
private:
|
||||||
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
|
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
|
||||||
|
|
||||||
void drawTiles(QPainter& painter);
|
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawBuildings(QPainter& painter);
|
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawStations(QPainter& painter);
|
void drawStations(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebris(QPainter& painter);
|
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawShips(QPainter& painter);
|
void drawShips(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebugSensorRanges(QPainter& painter);
|
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawDebugTargetLines(QPainter& painter);
|
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
void drawBeams(QPainter& painter);
|
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates);
|
||||||
|
|
||||||
float getTilePx() const;
|
// The world <-> widget transform for the current viewport size. The arena
|
||||||
QPointF worldToWidget(QVector2D worldPos) const;
|
// shows the whole world at once and never scrolls, so this fits the arena's
|
||||||
QPointF tileToWidget(QPoint tile) const;
|
// full extent into the widget; like GameWorldView's, it is a per-frame
|
||||||
QRectF tileRect(QPoint tile) const;
|
// snapshot rather than cached state.
|
||||||
|
WorldCoordinates getCoordinates() const;
|
||||||
|
|
||||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||||
QVector2D widgetToWorld(QPoint widgetPt) const;
|
|
||||||
|
|
||||||
struct ActiveBeam
|
struct ActiveBeam
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,8 +7,18 @@ 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
|
||||||
|
# (see WorldPrimitives.h). The balancing target does not link the ui library,
|
||||||
|
# so the few ui files it needs are compiled into it, as above.
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +32,11 @@ 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
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|||||||
272
src/lib/core/BuildModeController.cpp
Normal file
272
src/lib/core/BuildModeController.cpp
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
#include "BuildModeController.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "BlueprintModeExitedEvent.h"
|
||||||
|
#include "BuilderModeExitedEvent.h"
|
||||||
|
#include "DeconstructModeChangedEvent.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
Rotation rotateClockwise(Rotation rotation)
|
||||||
|
{
|
||||||
|
switch (rotation)
|
||||||
|
{
|
||||||
|
case Rotation::North: return Rotation::East;
|
||||||
|
case Rotation::East: return Rotation::South;
|
||||||
|
case Rotation::South: return Rotation::West;
|
||||||
|
case Rotation::West: return Rotation::North;
|
||||||
|
}
|
||||||
|
return Rotation::East;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rotation rotateCounterClockwise(Rotation rotation)
|
||||||
|
{
|
||||||
|
switch (rotation)
|
||||||
|
{
|
||||||
|
case Rotation::North: return Rotation::West;
|
||||||
|
case Rotation::East: return Rotation::North;
|
||||||
|
case Rotation::South: return Rotation::East;
|
||||||
|
case Rotation::West: return Rotation::South;
|
||||||
|
}
|
||||||
|
return Rotation::East;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
BuildMode BuildModeController::getMode() const
|
||||||
|
{
|
||||||
|
return m_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isBuilderMode() const
|
||||||
|
{
|
||||||
|
return m_mode == BuildMode::Builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isBlueprintMode() const
|
||||||
|
{
|
||||||
|
return m_mode == BuildMode::Blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isDeconstructMode() const
|
||||||
|
{
|
||||||
|
return m_mode == BuildMode::Deconstruct;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::enterMode(BuildMode mode)
|
||||||
|
{
|
||||||
|
if (m_mode == mode) { return; }
|
||||||
|
|
||||||
|
// Leave the current mode properly, so its widget hears about it however the
|
||||||
|
// player left. Each exit clears only its own state.
|
||||||
|
switch (m_mode)
|
||||||
|
{
|
||||||
|
case BuildMode::Builder:
|
||||||
|
m_draggingBelt = false;
|
||||||
|
m_beltDragPath.clear();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BuilderModeExitedEvent>());
|
||||||
|
break;
|
||||||
|
case BuildMode::Blueprint:
|
||||||
|
m_hoveredGhostIsTransfer = false;
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintModeExitedEvent>());
|
||||||
|
break;
|
||||||
|
case BuildMode::Deconstruct:
|
||||||
|
m_deconstructHoverBuildingId.reset();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<DeconstructModeChangedEvent>(false));
|
||||||
|
break;
|
||||||
|
case BuildMode::None:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_mode = mode;
|
||||||
|
|
||||||
|
if (mode == BuildMode::Deconstruct)
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<DeconstructModeChangedEvent>(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::enterBuilderMode(BuildingType type)
|
||||||
|
{
|
||||||
|
enterMode(BuildMode::Builder);
|
||||||
|
m_builderType = type;
|
||||||
|
m_ghostRotation = Rotation::East;
|
||||||
|
m_ghostValid = false;
|
||||||
|
m_tunnelGhostType = BuildingType::TunnelEntry;
|
||||||
|
m_tunnelPartnerTile.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::enterBlueprintMode(Blueprint blueprint)
|
||||||
|
{
|
||||||
|
enterMode(BuildMode::Blueprint);
|
||||||
|
// The layout starts where the builder ghost last was, so switching from a
|
||||||
|
// building to a blueprint does not jump the preview across the world.
|
||||||
|
m_blueprintGhostTile = m_ghostTile;
|
||||||
|
m_blueprint = std::move(blueprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::toggleDeconstructMode()
|
||||||
|
{
|
||||||
|
enterMode(isDeconstructMode() ? BuildMode::None : BuildMode::Deconstruct);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::exitBuilderMode()
|
||||||
|
{
|
||||||
|
if (!isBuilderMode()) { return; }
|
||||||
|
enterMode(BuildMode::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::exitBlueprintMode()
|
||||||
|
{
|
||||||
|
if (!isBlueprintMode()) { return; }
|
||||||
|
enterMode(BuildMode::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::exitCurrentMode()
|
||||||
|
{
|
||||||
|
enterMode(BuildMode::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildingType BuildModeController::getBuilderType() const
|
||||||
|
{
|
||||||
|
return m_builderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isTunnelMode() const
|
||||||
|
{
|
||||||
|
return isBuilderMode() && m_builderType == BuildingType::TunnelEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildingType BuildModeController::getEffectiveBuilderType() const
|
||||||
|
{
|
||||||
|
return isTunnelMode() ? m_tunnelGhostType : m_builderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint BuildModeController::getGhostTile() const
|
||||||
|
{
|
||||||
|
return m_ghostTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rotation BuildModeController::getGhostRotation() const
|
||||||
|
{
|
||||||
|
return m_ghostRotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isGhostValid() const
|
||||||
|
{
|
||||||
|
return m_ghostValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setGhostTile(QPoint tile)
|
||||||
|
{
|
||||||
|
m_ghostTile = tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setGhostValidity(bool valid)
|
||||||
|
{
|
||||||
|
m_ghostValid = valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::rotateGhost(bool clockwise)
|
||||||
|
{
|
||||||
|
m_ghostRotation = clockwise ? rotateClockwise(m_ghostRotation)
|
||||||
|
: rotateCounterClockwise(m_ghostRotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildingType BuildModeController::getTunnelGhostType() const
|
||||||
|
{
|
||||||
|
return m_tunnelGhostType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::optional<QPoint>& BuildModeController::getTunnelPartnerTile() const
|
||||||
|
{
|
||||||
|
return m_tunnelPartnerTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setTunnelGhost(BuildingType resolvedType,
|
||||||
|
std::optional<QPoint> partnerTile)
|
||||||
|
{
|
||||||
|
m_tunnelGhostType = resolvedType;
|
||||||
|
m_tunnelPartnerTile = std::move(partnerTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isDraggingBelt() const
|
||||||
|
{
|
||||||
|
return m_draggingBelt;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint BuildModeController::getBeltDragAnchor() const
|
||||||
|
{
|
||||||
|
return m_beltDragAnchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<BeltPathTile>& BuildModeController::getBeltDragPath() const
|
||||||
|
{
|
||||||
|
return m_beltDragPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::beginBeltDrag(QPoint anchorTile)
|
||||||
|
{
|
||||||
|
m_draggingBelt = true;
|
||||||
|
m_beltDragAnchor = anchorTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setBeltDragPath(std::vector<BeltPathTile> path)
|
||||||
|
{
|
||||||
|
m_beltDragPath = std::move(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::cancelBeltDrag()
|
||||||
|
{
|
||||||
|
m_draggingBelt = false;
|
||||||
|
m_beltDragPath.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const Blueprint& BuildModeController::getBlueprint() const
|
||||||
|
{
|
||||||
|
return m_blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
Blueprint& BuildModeController::getMutableBlueprint()
|
||||||
|
{
|
||||||
|
return m_blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint BuildModeController::getBlueprintGhostTile() const
|
||||||
|
{
|
||||||
|
return m_blueprintGhostTile;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setBlueprintGhostTile(QPoint tile)
|
||||||
|
{
|
||||||
|
m_blueprintGhostTile = tile;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BuildModeController::isHoveredGhostTransfer() const
|
||||||
|
{
|
||||||
|
return m_hoveredGhostIsTransfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setHoveredGhostTransfer(bool transfer)
|
||||||
|
{
|
||||||
|
m_hoveredGhostIsTransfer = transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::optional<BuildingId>&
|
||||||
|
BuildModeController::getDeconstructHoverBuildingId() const
|
||||||
|
{
|
||||||
|
return m_deconstructHoverBuildingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildModeController::setDeconstructHoverBuildingId(std::optional<BuildingId> id)
|
||||||
|
{
|
||||||
|
m_deconstructHoverBuildingId = std::move(id);
|
||||||
|
}
|
||||||
133
src/lib/core/BuildModeController.h
Normal file
133
src/lib/core/BuildModeController.h
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
|
||||||
|
#include "BeltDragPath.h"
|
||||||
|
#include "Blueprint.h"
|
||||||
|
#include "BuildingId.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
|
||||||
|
// Which of the mutually exclusive world-interaction modes is active
|
||||||
|
// (REQ-UI-HOTKEYS, REQ-BLD-GHOST, REQ-UI-BLUEPRINT-PLACE, REQ-BLD-DECONSTRUCT).
|
||||||
|
enum class BuildMode
|
||||||
|
{
|
||||||
|
None, // plain selection
|
||||||
|
Builder, // placing one building type, ghost following the cursor
|
||||||
|
Blueprint, // placing a saved multi-building layout
|
||||||
|
Deconstruct // marking buildings for demolition
|
||||||
|
};
|
||||||
|
|
||||||
|
// The active build mode and the transient state that belongs to it.
|
||||||
|
//
|
||||||
|
// These modes were previously three independent flags, and every entry point
|
||||||
|
// cleared the other two by hand — inconsistently, which is how entering builder
|
||||||
|
// mode came to drop a blueprint without announcing it. Here exclusivity is
|
||||||
|
// structural: one mode is active, and every transition runs through enterMode(),
|
||||||
|
// which exits whatever was active first and publishes the same events regardless
|
||||||
|
// of which way the player got there.
|
||||||
|
//
|
||||||
|
// Everything needing the simulation — placement validity, tunnel matching, belt
|
||||||
|
// path building — stays with the caller, which computes and hands back the result
|
||||||
|
// (setGhostValidity, setTunnelGhost, setBeltDragPath). That keeps this a plain
|
||||||
|
// value that can be tested without a world.
|
||||||
|
class BuildModeController
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
BuildMode getMode() const;
|
||||||
|
bool isBuilderMode() const;
|
||||||
|
bool isBlueprintMode() const;
|
||||||
|
bool isDeconstructMode() const;
|
||||||
|
|
||||||
|
// --- transitions ----------------------------------------------------------
|
||||||
|
// Each leaves the previously active mode with its proper exit event.
|
||||||
|
void enterBuilderMode(BuildingType type);
|
||||||
|
void enterBlueprintMode(Blueprint blueprint);
|
||||||
|
// Leaves deconstruct mode if it is active, enters it otherwise
|
||||||
|
// (REQ-BLD-DECONSTRUCT-CLICK).
|
||||||
|
void toggleDeconstructMode();
|
||||||
|
void exitBuilderMode();
|
||||||
|
void exitBlueprintMode();
|
||||||
|
// Backs out of whichever mode is active, if any (the Q key and right-click).
|
||||||
|
void exitCurrentMode();
|
||||||
|
|
||||||
|
// --- builder mode ---------------------------------------------------------
|
||||||
|
// Only meaningful while isBuilderMode().
|
||||||
|
BuildingType getBuilderType() const;
|
||||||
|
// True while the builder type is TunnelEntry, where the ghost resolves to an
|
||||||
|
// entry or an exit by hovered position (REQ-BLD-TUNNEL-MODE).
|
||||||
|
bool isTunnelMode() const;
|
||||||
|
// The type the ghost currently represents: the position-resolved tunnel type in
|
||||||
|
// tunnel mode, the plain builder type otherwise.
|
||||||
|
BuildingType getEffectiveBuilderType() const;
|
||||||
|
|
||||||
|
QPoint getGhostTile() const;
|
||||||
|
Rotation getGhostRotation() const;
|
||||||
|
bool isGhostValid() const;
|
||||||
|
void setGhostTile(QPoint tile);
|
||||||
|
void setGhostValidity(bool valid);
|
||||||
|
// Turns the ghost one quarter turn. Validity is not rechecked here; the caller
|
||||||
|
// does that and calls setGhostValidity, because only it can see the world.
|
||||||
|
void rotateGhost(bool clockwise);
|
||||||
|
|
||||||
|
BuildingType getTunnelGhostType() const;
|
||||||
|
const std::optional<QPoint>& getTunnelPartnerTile() const;
|
||||||
|
void setTunnelGhost(BuildingType resolvedType, std::optional<QPoint> partnerTile);
|
||||||
|
|
||||||
|
// --- belt drag placement (REQ-BLD-BELT-DRAG) ------------------------------
|
||||||
|
bool isDraggingBelt() const;
|
||||||
|
QPoint getBeltDragAnchor() const;
|
||||||
|
const std::vector<BeltPathTile>& getBeltDragPath() const;
|
||||||
|
void beginBeltDrag(QPoint anchorTile);
|
||||||
|
void setBeltDragPath(std::vector<BeltPathTile> path);
|
||||||
|
// Drops the drag without placing anything, staying in builder mode.
|
||||||
|
void cancelBeltDrag();
|
||||||
|
|
||||||
|
// --- blueprint mode -------------------------------------------------------
|
||||||
|
// Only meaningful while isBlueprintMode().
|
||||||
|
const Blueprint& getBlueprint() const;
|
||||||
|
// Mutable so the caller can rotate the layout in place; rotating a blueprint
|
||||||
|
// needs building footprints from the config, which does not belong here.
|
||||||
|
Blueprint& getMutableBlueprint();
|
||||||
|
QPoint getBlueprintGhostTile() const;
|
||||||
|
void setBlueprintGhostTile(QPoint tile);
|
||||||
|
|
||||||
|
// Whether the ghost under the cursor would hand its settings to the building
|
||||||
|
// already there rather than place anything (REQ-UI-BLUEPRINT-TRANSFER). Classifying
|
||||||
|
// it needs the factory state, so the caller resolves it and stores the answer here,
|
||||||
|
// as with setGhostValidity. Kept here rather than recomputed per reader so the
|
||||||
|
// click, the ghost's colour, and the controls panel cannot disagree about what the
|
||||||
|
// cursor is over.
|
||||||
|
bool isHoveredGhostTransfer() const;
|
||||||
|
void setHoveredGhostTransfer(bool transfer);
|
||||||
|
|
||||||
|
// --- deconstruct mode -----------------------------------------------------
|
||||||
|
const std::optional<BuildingId>& getDeconstructHoverBuildingId() const;
|
||||||
|
void setDeconstructHoverBuildingId(std::optional<BuildingId> id);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// The single transition point: leaves the active mode, then enters `mode`.
|
||||||
|
void enterMode(BuildMode mode);
|
||||||
|
|
||||||
|
BuildMode m_mode = BuildMode::None;
|
||||||
|
|
||||||
|
BuildingType m_builderType = BuildingType::Belt;
|
||||||
|
QPoint m_ghostTile;
|
||||||
|
Rotation m_ghostRotation = Rotation::East;
|
||||||
|
bool m_ghostValid = false;
|
||||||
|
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
|
||||||
|
std::optional<QPoint> m_tunnelPartnerTile;
|
||||||
|
|
||||||
|
bool m_draggingBelt = false;
|
||||||
|
QPoint m_beltDragAnchor;
|
||||||
|
std::vector<BeltPathTile> m_beltDragPath;
|
||||||
|
|
||||||
|
Blueprint m_blueprint;
|
||||||
|
QPoint m_blueprintGhostTile;
|
||||||
|
bool m_hoveredGhostIsTransfer = false;
|
||||||
|
|
||||||
|
std::optional<BuildingId> m_deconstructHoverBuildingId;
|
||||||
|
};
|
||||||
@@ -53,6 +53,20 @@ bool isBeltSubsystemType(BuildingType type)
|
|||||||
|| type == BuildingType::TunnelExit;
|
|| type == BuildingType::TunnelExit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isConfigurableBuildingType(BuildingType type)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case BuildingType::Miner: // recipe (REQ-BLD-MINER)
|
||||||
|
case BuildingType::Assembler: // recipe (REQ-BLD-ASSEMBLER)
|
||||||
|
case BuildingType::Shipyard: // schematic and layout (REQ-BLD-SHIPYARD, REQ-MOD-LAYOUT)
|
||||||
|
case BuildingType::Splitter: // output filters (REQ-BLD-SPLITTER)
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool isProductionBuildingType(BuildingType type)
|
bool isProductionBuildingType(BuildingType type)
|
||||||
{
|
{
|
||||||
switch (type)
|
switch (type)
|
||||||
|
|||||||
@@ -43,3 +43,9 @@ bool isProductionBuildingType(BuildingType type);
|
|||||||
// rather than in the Building instance, so placing/removing them must register or
|
// rather than in the Building instance, so placing/removing them must register or
|
||||||
// unregister a tile with BeltSystem.
|
// unregister a tile with BeltSystem.
|
||||||
bool isBeltSubsystemType(BuildingType type);
|
bool isBeltSubsystemType(BuildingType type);
|
||||||
|
|
||||||
|
// Building types with player-facing settings that a blueprint can carry and hand to an
|
||||||
|
// existing building (REQ-UI-BLUEPRINT-TRANSFER): Miner and Assembler (recipe), Shipyard
|
||||||
|
// (schematic and module layout), Splitter (output filters). Every other type has nothing
|
||||||
|
// to configure, so a blueprint of one has nothing to transfer.
|
||||||
|
bool isConfigurableBuildingType(BuildingType type);
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +30,11 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/TunnelCompletion.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCoordinates.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldCamera.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionController.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildModeController.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlAction.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
263
src/lib/core/ControlAction.cpp
Normal file
263
src/lib/core/ControlAction.cpp
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
#include "ControlAction.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// A binding that a belt drag can take over. Availability is a property of the action,
|
||||||
|
// but a binding can be claimed by a different action while a gesture is in progress:
|
||||||
|
// right-click cancels the drag instead of leaving builder mode (REQ-BLD-BELT-DRAG), so
|
||||||
|
// ExitMode's right-click drops out and it is left with Q alone.
|
||||||
|
enum class BindingCondition
|
||||||
|
{
|
||||||
|
Always,
|
||||||
|
NotDraggingBelt
|
||||||
|
};
|
||||||
|
|
||||||
|
struct KeyBindingEntry
|
||||||
|
{
|
||||||
|
ControlAction action;
|
||||||
|
int key;
|
||||||
|
// Shown on the badge, and Ctrl additionally participates in matching. Every other
|
||||||
|
// modifier is display only -- see resolveKeyAction.
|
||||||
|
Qt::KeyboardModifiers modifiers;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MouseBindingEntry
|
||||||
|
{
|
||||||
|
ControlAction action;
|
||||||
|
MouseBinding binding;
|
||||||
|
BindingCondition condition;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolution is first-match-wins over these tables, so an entry that must beat another
|
||||||
|
// on the same input is listed above it -- CancelBeltLine before ExitMode on the right
|
||||||
|
// mouse button. Everything else is disjoint by availability.
|
||||||
|
const KeyBindingEntry KEY_BINDINGS[] = {
|
||||||
|
{ControlAction::Move, Qt::Key_A, Qt::NoModifier},
|
||||||
|
{ControlAction::Move, Qt::Key_D, Qt::NoModifier},
|
||||||
|
{ControlAction::GameSpeed, Qt::Key_W, Qt::NoModifier},
|
||||||
|
{ControlAction::GameSpeed, Qt::Key_S, Qt::NoModifier},
|
||||||
|
{ControlAction::TogglePause, Qt::Key_Space, Qt::NoModifier},
|
||||||
|
{ControlAction::CopyTemporary, Qt::Key_C, Qt::NoModifier},
|
||||||
|
{ControlAction::CreateBlueprint, Qt::Key_C, Qt::ControlModifier},
|
||||||
|
{ControlAction::PasteTemporary, Qt::Key_V, Qt::NoModifier},
|
||||||
|
{ControlAction::OpenBlueprints, Qt::Key_V, Qt::ControlModifier},
|
||||||
|
// One binding, two badges: Shift picks the rotation direction and is read by the
|
||||||
|
// handler, the way a build hotkey's digit is (REQ-BLD-ROTATE). Both entries match
|
||||||
|
// the same press, and both resolve to the same action, so listing them twice costs
|
||||||
|
// nothing and is what puts "R" and "Shift+R" on the row.
|
||||||
|
{ControlAction::Rotate, Qt::Key_R, Qt::NoModifier},
|
||||||
|
{ControlAction::Rotate, Qt::Key_R, Qt::ShiftModifier},
|
||||||
|
{ControlAction::EnterDeconstruct, Qt::Key_Q, Qt::NoModifier},
|
||||||
|
{ControlAction::ExitMode, Qt::Key_Q, Qt::NoModifier},
|
||||||
|
{ControlAction::OpenMenu, Qt::Key_Escape, Qt::NoModifier},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MouseBindingEntry MOUSE_BINDINGS[] = {
|
||||||
|
{ControlAction::Select, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::Place, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ApplySettings, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ToggleDeconstruct, MouseBinding::LeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::SelectArea, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::PlaceBeltLine, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::DeconstructArea, MouseBinding::LeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::AddToSelection, MouseBinding::CtrlLeftClick, BindingCondition::Always},
|
||||||
|
{ControlAction::AddAreaToSelection, MouseBinding::CtrlLeftDrag, BindingCondition::Always},
|
||||||
|
{ControlAction::CancelBeltLine, MouseBinding::RightClick, BindingCondition::Always},
|
||||||
|
{ControlAction::ExitMode, MouseBinding::RightClick, BindingCondition::NotDraggingBelt},
|
||||||
|
};
|
||||||
|
|
||||||
|
bool isConditionMet(BindingCondition condition, const ControlContext& context)
|
||||||
|
{
|
||||||
|
if (condition == BindingCondition::NotDraggingBelt) { return !context.draggingBelt; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPlacementMode(const ControlContext& context)
|
||||||
|
{
|
||||||
|
return context.mode == BuildMode::Builder || context.mode == BuildMode::Blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> filterAvailable(const std::vector<ControlAction>& actions,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
std::vector<ControlAction> available;
|
||||||
|
for (ControlAction action : actions)
|
||||||
|
{
|
||||||
|
if (isControlActionAvailable(action, context)) { available.push_back(action); }
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool isControlActionAvailable(ControlAction action, const ControlContext& context)
|
||||||
|
{
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case ControlAction::None:
|
||||||
|
return false;
|
||||||
|
|
||||||
|
case ControlAction::Move:
|
||||||
|
case ControlAction::GameSpeed:
|
||||||
|
case ControlAction::TogglePause:
|
||||||
|
case ControlAction::OpenBlueprints:
|
||||||
|
case ControlAction::OpenMenu:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Does nothing until something has been captured with C, so it is not offered
|
||||||
|
// before then (REQ-UI-BLUEPRINT-TEMP, REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
case ControlAction::PasteTemporary:
|
||||||
|
return context.temporaryBlueprintExists;
|
||||||
|
|
||||||
|
case ControlAction::Select:
|
||||||
|
case ControlAction::SelectArea:
|
||||||
|
case ControlAction::AddToSelection:
|
||||||
|
case ControlAction::AddAreaToSelection:
|
||||||
|
case ControlAction::EnterDeconstruct:
|
||||||
|
return context.mode == BuildMode::None;
|
||||||
|
|
||||||
|
// Both need something a blueprint can be made of; a selection of ships or debris
|
||||||
|
// leaves them inert (REQ-UI-HOTKEYS).
|
||||||
|
case ControlAction::CopyTemporary:
|
||||||
|
case ControlAction::CreateBlueprint:
|
||||||
|
return context.mode == BuildMode::None && context.placeableBuildingSelected;
|
||||||
|
|
||||||
|
// Place and ApplySettings are the same click; which one it is depends on whether
|
||||||
|
// the ghost under the cursor is a transfer target (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
case ControlAction::Place:
|
||||||
|
return isPlacementMode(context) && !context.hoveredGhostIsTransfer;
|
||||||
|
case ControlAction::ApplySettings:
|
||||||
|
return context.mode == BuildMode::Blueprint && context.hoveredGhostIsTransfer;
|
||||||
|
|
||||||
|
// The only building type placed by dragging (REQ-BLD-BELT-DRAG).
|
||||||
|
case ControlAction::PlaceBeltLine:
|
||||||
|
return context.mode == BuildMode::Builder
|
||||||
|
&& context.builderType == BuildingType::Belt;
|
||||||
|
|
||||||
|
case ControlAction::Rotate:
|
||||||
|
return isPlacementMode(context);
|
||||||
|
case ControlAction::CancelBeltLine:
|
||||||
|
return context.draggingBelt;
|
||||||
|
case ControlAction::ExitMode:
|
||||||
|
return context.mode != BuildMode::None;
|
||||||
|
|
||||||
|
case ControlAction::ToggleDeconstruct:
|
||||||
|
case ControlAction::DeconstructArea:
|
||||||
|
return context.mode == BuildMode::Deconstruct;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
std::vector<ControlBinding> bindings;
|
||||||
|
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.action != action) { continue; }
|
||||||
|
if (!isConditionMet(entry.condition, context)) { continue; }
|
||||||
|
ControlBinding binding;
|
||||||
|
binding.isMouse = true;
|
||||||
|
binding.mouse = entry.binding;
|
||||||
|
bindings.push_back(binding);
|
||||||
|
}
|
||||||
|
for (const KeyBindingEntry& entry : KEY_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.action != action) { continue; }
|
||||||
|
ControlBinding binding;
|
||||||
|
binding.key = entry.key;
|
||||||
|
binding.modifiers = entry.modifiers;
|
||||||
|
bindings.push_back(binding);
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContextKind getControlContextKind(const ControlContext& context)
|
||||||
|
{
|
||||||
|
switch (context.mode)
|
||||||
|
{
|
||||||
|
case BuildMode::Builder: return ControlContextKind::Build;
|
||||||
|
case BuildMode::Blueprint: return ControlContextKind::Blueprint;
|
||||||
|
case BuildMode::Deconstruct: return ControlContextKind::Deconstruct;
|
||||||
|
case BuildMode::None: break;
|
||||||
|
}
|
||||||
|
return context.selection == ControlSelection::None ? ControlContextKind::General
|
||||||
|
: ControlContextKind::Selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> getContextActions(const ControlContext& context)
|
||||||
|
{
|
||||||
|
// The candidates of each context, in the order REQ-UI-CONTROLS-CONTENT lists them,
|
||||||
|
// then filtered by availability.
|
||||||
|
//
|
||||||
|
// The General and Selection lists differ rather than one filtered list serving
|
||||||
|
// both, because the additive-selection rows are an omission and not an
|
||||||
|
// unavailability: Ctrl+click does work with nothing selected, it just picks the
|
||||||
|
// object like a plain click would. "Add / remove from selection" is a row that
|
||||||
|
// means nothing until there is a selection to add to, so it waits for one
|
||||||
|
// (REQ-UI-CONTROLS-ACCURACY permits omitting an available binding).
|
||||||
|
switch (getControlContextKind(context))
|
||||||
|
{
|
||||||
|
case ControlContextKind::Build:
|
||||||
|
case ControlContextKind::Blueprint:
|
||||||
|
return filterAvailable({ControlAction::Place, ControlAction::ApplySettings,
|
||||||
|
ControlAction::PlaceBeltLine, ControlAction::Rotate,
|
||||||
|
ControlAction::CancelBeltLine, ControlAction::ExitMode},
|
||||||
|
context);
|
||||||
|
case ControlContextKind::Deconstruct:
|
||||||
|
return filterAvailable({ControlAction::ToggleDeconstruct,
|
||||||
|
ControlAction::DeconstructArea, ControlAction::ExitMode},
|
||||||
|
context);
|
||||||
|
case ControlContextKind::Selection:
|
||||||
|
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
|
||||||
|
ControlAction::AddToSelection,
|
||||||
|
ControlAction::AddAreaToSelection,
|
||||||
|
ControlAction::EnterDeconstruct,
|
||||||
|
ControlAction::CopyTemporary,
|
||||||
|
ControlAction::CreateBlueprint},
|
||||||
|
context);
|
||||||
|
case ControlContextKind::General:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return filterAvailable({ControlAction::Select, ControlAction::SelectArea,
|
||||||
|
ControlAction::EnterDeconstruct},
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context)
|
||||||
|
{
|
||||||
|
return filterAvailable({ControlAction::Move, ControlAction::GameSpeed,
|
||||||
|
ControlAction::TogglePause, ControlAction::PasteTemporary,
|
||||||
|
ControlAction::OpenBlueprints, ControlAction::OpenMenu},
|
||||||
|
context);
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
|
||||||
|
const ControlContext& context)
|
||||||
|
{
|
||||||
|
// Ctrl distinguishes a chord from the bare key (Ctrl+C is not C); every other
|
||||||
|
// modifier is ignored, so Shift+A still pans and Shift+R still rotates. This is
|
||||||
|
// what the key handler has always done, and matching modifiers exactly instead
|
||||||
|
// would silently drop those presses.
|
||||||
|
const bool controlHeld = (modifiers & Qt::ControlModifier) != 0;
|
||||||
|
for (const KeyBindingEntry& entry : KEY_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.key != key) { continue; }
|
||||||
|
const bool entryNeedsControl = (entry.modifiers & Qt::ControlModifier) != 0;
|
||||||
|
if (entryNeedsControl != controlHeld) { continue; }
|
||||||
|
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
|
||||||
|
}
|
||||||
|
return ControlAction::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context)
|
||||||
|
{
|
||||||
|
for (const MouseBindingEntry& entry : MOUSE_BINDINGS)
|
||||||
|
{
|
||||||
|
if (entry.binding != binding) { continue; }
|
||||||
|
if (!isConditionMet(entry.condition, context)) { continue; }
|
||||||
|
if (isControlActionAvailable(entry.action, context)) { return entry.action; }
|
||||||
|
}
|
||||||
|
return ControlAction::None;
|
||||||
|
}
|
||||||
164
src/lib/core/ControlAction.h
Normal file
164
src/lib/core/ControlAction.h
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <Qt>
|
||||||
|
|
||||||
|
#include "BuildModeController.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
|
||||||
|
// The single statement of what the player can do right now, and which input does it
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT, REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// This file declares actions; it never performs them and it never names them. It knows
|
||||||
|
// an action's bindings and the situations in which it does something -- nothing about
|
||||||
|
// the simulation, the widgets, the events an action ends up firing, or the words shown
|
||||||
|
// to the player. Display text lives in ui/ControlActionText.h, which formats the
|
||||||
|
// bindings this hands it, so a badge is derived from the real binding rather than
|
||||||
|
// typed beside it.
|
||||||
|
//
|
||||||
|
// Three readers, all of them consuming this and none of them extending it:
|
||||||
|
//
|
||||||
|
// * ControlsPanel calls getContextActions()/getAlwaysAvailableActions() and draws them.
|
||||||
|
// * InputMapper calls resolveKeyAction() and fires the event the action stands for.
|
||||||
|
// * GameWorldView calls resolveMouseAction() and runs the branch it already ran.
|
||||||
|
//
|
||||||
|
// Two bindings are deliberately absent. Build hotkeys (REQ-UI-HOTKEYS) are advertised
|
||||||
|
// on the build buttons instead of in the panel, and InputMapper::getBuildHotkeyLabel
|
||||||
|
// already derives their badges from the same table the handler switches on, so they
|
||||||
|
// have no drift to fix. F3/F4 are development controls and appear nowhere
|
||||||
|
// (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// When bindings become player-configurable, only the binding tables in the .cpp turn
|
||||||
|
// from hard-coded data into loaded data. The actions, the availability rules, the
|
||||||
|
// panel, and every handler are unaffected.
|
||||||
|
enum class ControlAction
|
||||||
|
{
|
||||||
|
None, // no action is bound to the queried input in the queried context
|
||||||
|
|
||||||
|
// Always available (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
Move,
|
||||||
|
GameSpeed,
|
||||||
|
TogglePause,
|
||||||
|
PasteTemporary,
|
||||||
|
OpenBlueprints,
|
||||||
|
OpenMenu,
|
||||||
|
|
||||||
|
// No build mode active.
|
||||||
|
Select,
|
||||||
|
SelectArea,
|
||||||
|
AddToSelection,
|
||||||
|
AddAreaToSelection,
|
||||||
|
EnterDeconstruct,
|
||||||
|
|
||||||
|
// No build mode active, with something selected.
|
||||||
|
CopyTemporary,
|
||||||
|
CreateBlueprint,
|
||||||
|
|
||||||
|
// Builder and blueprint placement mode.
|
||||||
|
Place,
|
||||||
|
ApplySettings,
|
||||||
|
PlaceBeltLine,
|
||||||
|
Rotate,
|
||||||
|
CancelBeltLine,
|
||||||
|
ExitMode,
|
||||||
|
|
||||||
|
// Deconstruct mode.
|
||||||
|
ToggleDeconstruct,
|
||||||
|
DeconstructArea
|
||||||
|
};
|
||||||
|
|
||||||
|
// The mouse gestures that carry a binding. Each is a whole gesture rather than a raw
|
||||||
|
// event: a drag is one binding, not a press plus a release, because that is the unit
|
||||||
|
// the player and the panel both think in. Which events make up the gesture, and the
|
||||||
|
// state it runs on, stay with the widget that owns them.
|
||||||
|
enum class MouseBinding
|
||||||
|
{
|
||||||
|
LeftClick,
|
||||||
|
LeftDrag,
|
||||||
|
CtrlLeftClick,
|
||||||
|
CtrlLeftDrag,
|
||||||
|
RightClick
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which selection category is held, mirroring REQ-UI-SELECTION-CATEGORIES without
|
||||||
|
// depending on SelectionController.
|
||||||
|
enum class ControlSelection
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Buildings,
|
||||||
|
FieldObjects
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which card the panel is showing (REQ-UI-CONTROLS-CONTENT). Named rather than
|
||||||
|
// spelled, so the heading text stays a presentation concern.
|
||||||
|
enum class ControlContextKind
|
||||||
|
{
|
||||||
|
General,
|
||||||
|
Selection,
|
||||||
|
Build,
|
||||||
|
Blueprint,
|
||||||
|
Deconstruct
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything the availability rules are allowed to depend on, as a plain snapshot.
|
||||||
|
//
|
||||||
|
// Taking a snapshot rather than references to the live controllers is what keeps this
|
||||||
|
// testable without a world, and it is what stops an action from reaching into the
|
||||||
|
// simulation: if a rule needs a fact, the fact is named here and the caller supplies it.
|
||||||
|
struct ControlContext
|
||||||
|
{
|
||||||
|
BuildMode mode = BuildMode::None;
|
||||||
|
BuildingType builderType = BuildingType::Belt; // while mode == Builder
|
||||||
|
bool draggingBelt = false;
|
||||||
|
// A single-building blueprint whose ghost is over a configuration-transfer target,
|
||||||
|
// so clicking hands over settings rather than placing (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
bool hoveredGhostIsTransfer = false;
|
||||||
|
ControlSelection selection = ControlSelection::None;
|
||||||
|
int selectionCount = 0;
|
||||||
|
// At least one selected building is player-placeable, the condition under which
|
||||||
|
// C and Ctrl+C do anything (REQ-UI-HOTKEYS).
|
||||||
|
bool placeableBuildingSelected = false;
|
||||||
|
bool temporaryBlueprintExists = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One input an action answers to, in structured form so the badge can be rendered from
|
||||||
|
// it. `modifiers` is what the badge shows; matching is looser than equality, see
|
||||||
|
// resolveKeyAction.
|
||||||
|
struct ControlBinding
|
||||||
|
{
|
||||||
|
bool isMouse = false;
|
||||||
|
MouseBinding mouse = MouseBinding::LeftClick;
|
||||||
|
int key = 0; // Qt::Key_*, when !isMouse
|
||||||
|
Qt::KeyboardModifiers modifiers = Qt::NoModifier;
|
||||||
|
};
|
||||||
|
|
||||||
|
// True when triggering the action in this context would do what its label says. The
|
||||||
|
// panel shows exactly the available actions, and the resolvers return only available
|
||||||
|
// ones, which is REQ-UI-CONTROLS-ACCURACY expressed as one function.
|
||||||
|
bool isControlActionAvailable(ControlAction action, const ControlContext& context);
|
||||||
|
|
||||||
|
// The inputs an action answers to, in the order the panel should badge them.
|
||||||
|
// Context-dependent because a binding can be taken over: while a belt drag is in
|
||||||
|
// progress the right mouse button cancels the drag, so ExitMode is left with its key
|
||||||
|
// binding alone (REQ-BLD-BELT-DRAG).
|
||||||
|
std::vector<ControlBinding> getControlActionBindings(ControlAction action,
|
||||||
|
const ControlContext& context);
|
||||||
|
|
||||||
|
// Which card is showing, and the rows it holds -- the context's own, then the block
|
||||||
|
// available everywhere (REQ-UI-CONTROLS-CARD). Both lists are already filtered to the
|
||||||
|
// available actions and ordered as REQ-UI-CONTROLS-CONTENT lists them.
|
||||||
|
ControlContextKind getControlContextKind(const ControlContext& context);
|
||||||
|
std::vector<ControlAction> getContextActions(const ControlContext& context);
|
||||||
|
std::vector<ControlAction> getAlwaysAvailableActions(const ControlContext& context);
|
||||||
|
|
||||||
|
// The action a key press or a mouse gesture triggers here, or None when the input is
|
||||||
|
// unbound in this context. Both return only actions that are available, so a caller can
|
||||||
|
// act on the result without re-checking the situation.
|
||||||
|
//
|
||||||
|
// Rotation direction is not part of the action: R and Shift+R are one Rotate, and the
|
||||||
|
// caller reads the modifier for the direction, exactly as the digit of a build hotkey
|
||||||
|
// is read from the key. An action with a parameter keeps the parameter at the handler.
|
||||||
|
ControlAction resolveKeyAction(int key, Qt::KeyboardModifiers modifiers,
|
||||||
|
const ControlContext& context);
|
||||||
|
ControlAction resolveMouseAction(MouseBinding binding, const ControlContext& context);
|
||||||
165
src/lib/core/SelectionController.cpp
Normal file
165
src/lib/core/SelectionController.cpp
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
#include "SelectionController.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "DebrisSelectionChangedEvent.h"
|
||||||
|
#include "EntitySelectionChangedEvent.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
#include "SelectionChangedEvent.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
bool contains(const std::vector<T>& items, const T& item)
|
||||||
|
{
|
||||||
|
return std::find(items.begin(), items.end(), item) != items.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies `hits` to `selection` per `mode`. Replace is handled by the caller so
|
||||||
|
// that an empty hit list can mean "clear this category" there but "leave this
|
||||||
|
// category alone" here.
|
||||||
|
template <typename T>
|
||||||
|
void combine(std::vector<T>& selection, const std::vector<T>& hits, SelectionMode mode)
|
||||||
|
{
|
||||||
|
for (const T& hit : hits)
|
||||||
|
{
|
||||||
|
const typename std::vector<T>::iterator it =
|
||||||
|
std::find(selection.begin(), selection.end(), hit);
|
||||||
|
if (it == selection.end())
|
||||||
|
{
|
||||||
|
selection.push_back(hit);
|
||||||
|
}
|
||||||
|
else if (mode == SelectionMode::Toggle)
|
||||||
|
{
|
||||||
|
// Only a toggle removes; an additive box drag never deselects.
|
||||||
|
selection.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
const std::vector<BuildingId>& SelectionController::getSelectedBuildings() const
|
||||||
|
{
|
||||||
|
return m_buildings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<entt::entity>& SelectionController::getSelectedActors() const
|
||||||
|
{
|
||||||
|
return m_actors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<entt::entity>& SelectionController::getSelectedDebris() const
|
||||||
|
{
|
||||||
|
return m_debris;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::isActorSelected(entt::entity actor) const
|
||||||
|
{
|
||||||
|
return contains(m_actors, actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::isDebrisSelected(entt::entity debris) const
|
||||||
|
{
|
||||||
|
return contains(m_debris, debris);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::selectBuildings(const std::vector<BuildingId>& ids,
|
||||||
|
SelectionMode mode)
|
||||||
|
{
|
||||||
|
// Buildings win over field objects (REQ-UI-SELECTION-CATEGORIES).
|
||||||
|
if (clearActorsQuietly()) { publishActors(); }
|
||||||
|
if (clearDebrisQuietly()) { publishDebris(); }
|
||||||
|
|
||||||
|
if (mode == SelectionMode::Replace)
|
||||||
|
{
|
||||||
|
m_buildings = ids;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
combine(m_buildings, ids, mode);
|
||||||
|
}
|
||||||
|
publishBuildings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::selectFieldObjects(const std::vector<entt::entity>& actors,
|
||||||
|
const std::vector<entt::entity>& debris,
|
||||||
|
SelectionMode mode)
|
||||||
|
{
|
||||||
|
if (clearBuildingsQuietly()) { publishBuildings(); }
|
||||||
|
|
||||||
|
if (mode == SelectionMode::Replace)
|
||||||
|
{
|
||||||
|
m_actors = actors;
|
||||||
|
m_debris = debris;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
combine(m_actors, actors, mode);
|
||||||
|
combine(m_debris, debris, mode);
|
||||||
|
}
|
||||||
|
publishActors();
|
||||||
|
publishDebris();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::clearAll()
|
||||||
|
{
|
||||||
|
if (clearBuildingsQuietly()) { publishBuildings(); }
|
||||||
|
if (clearActorsQuietly()) { publishActors(); }
|
||||||
|
if (clearDebrisQuietly()) { publishDebris(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::setSelectedActors(std::vector<entt::entity> actors)
|
||||||
|
{
|
||||||
|
if (actors == m_actors) { return; }
|
||||||
|
m_actors = std::move(actors);
|
||||||
|
publishActors();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::setSelectedDebris(std::vector<entt::entity> debris)
|
||||||
|
{
|
||||||
|
if (debris == m_debris) { return; }
|
||||||
|
m_debris = std::move(debris);
|
||||||
|
publishDebris();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::clearBuildingsQuietly()
|
||||||
|
{
|
||||||
|
if (m_buildings.empty()) { return false; }
|
||||||
|
m_buildings.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::clearActorsQuietly()
|
||||||
|
{
|
||||||
|
if (m_actors.empty()) { return false; }
|
||||||
|
m_actors.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SelectionController::clearDebrisQuietly()
|
||||||
|
{
|
||||||
|
if (m_debris.empty()) { return false; }
|
||||||
|
m_debris.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::publishBuildings() const
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<SelectionChangedEvent>(m_buildings));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::publishActors() const
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<EntitySelectionChangedEvent>(m_actors));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionController::publishDebris() const
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<DebrisSelectionChangedEvent>(m_debris));
|
||||||
|
}
|
||||||
75
src/lib/core/SelectionController.h
Normal file
75
src/lib/core/SelectionController.h
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "BuildingId.h"
|
||||||
|
#include "entt/entity/entity.hpp"
|
||||||
|
|
||||||
|
// How a new hit combines with what is already selected.
|
||||||
|
enum class SelectionMode
|
||||||
|
{
|
||||||
|
Replace, // plain click or drag: the hit becomes the whole selection
|
||||||
|
Toggle, // Ctrl + click: the hit joins the selection, or leaves it if present
|
||||||
|
Add // Ctrl + box drag: the hits join the selection, never leave it
|
||||||
|
};
|
||||||
|
|
||||||
|
// The player's current selection across the three categories, and the rules that
|
||||||
|
// govern moving between them (REQ-UI-SELECTION-CATEGORIES).
|
||||||
|
//
|
||||||
|
// The rules were previously written out once for point-clicks and once for box
|
||||||
|
// drags, which is why they live here now: buildings win over field objects, so
|
||||||
|
// selecting a building clears actors and debris, and selecting either of those
|
||||||
|
// clears buildings — but actors and debris coexist with each other
|
||||||
|
// (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-CLICK-SELECT, REQ-UI-MULTI-SELECT,
|
||||||
|
// REQ-UI-DEBRIS-MULTI-SELECT). A point-click and a box drag then differ only in
|
||||||
|
// the SelectionMode they pass and in how many hits they pass.
|
||||||
|
//
|
||||||
|
// Every mutator publishes the change events, so callers never emit them by hand.
|
||||||
|
// Hit-testing is not done here: callers resolve what was hit and pass the result
|
||||||
|
// in, which keeps this free of any simulation dependency.
|
||||||
|
class SelectionController
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
const std::vector<BuildingId>& getSelectedBuildings() const;
|
||||||
|
const std::vector<entt::entity>& getSelectedActors() const;
|
||||||
|
const std::vector<entt::entity>& getSelectedDebris() const;
|
||||||
|
|
||||||
|
bool isActorSelected(entt::entity actor) const;
|
||||||
|
bool isDebrisSelected(entt::entity debris) const;
|
||||||
|
|
||||||
|
// Selects buildings and/or construction sites, clearing any field selection.
|
||||||
|
// Always publishes SelectionChangedEvent, even when the result is unchanged,
|
||||||
|
// so a click on an already-selected building still refreshes its panel.
|
||||||
|
void selectBuildings(const std::vector<BuildingId>& ids, SelectionMode mode);
|
||||||
|
|
||||||
|
// Selects field objects, clearing any building selection. Actors and debris are
|
||||||
|
// set together so a Replace can express "these actors and no debris" — which is
|
||||||
|
// what a plain click on an actor means — while a Toggle or Add leaves the
|
||||||
|
// category whose vector is empty untouched. Always publishes both field events.
|
||||||
|
void selectFieldObjects(const std::vector<entt::entity>& actors,
|
||||||
|
const std::vector<entt::entity>& debris,
|
||||||
|
SelectionMode mode);
|
||||||
|
|
||||||
|
// Empties all three categories, publishing only for those that were non-empty.
|
||||||
|
void clearAll();
|
||||||
|
|
||||||
|
// Replace one category outright, publishing only if it actually changed. For
|
||||||
|
// the per-frame prune of entities that despawned or died: the liveness query
|
||||||
|
// belongs to the caller, which is the one that can see the simulation.
|
||||||
|
void setSelectedActors(std::vector<entt::entity> actors);
|
||||||
|
void setSelectedDebris(std::vector<entt::entity> debris);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Each returns whether anything changed, without publishing.
|
||||||
|
bool clearBuildingsQuietly();
|
||||||
|
bool clearActorsQuietly();
|
||||||
|
bool clearDebrisQuietly();
|
||||||
|
|
||||||
|
void publishBuildings() const;
|
||||||
|
void publishActors() const;
|
||||||
|
void publishDebris() const;
|
||||||
|
|
||||||
|
std::vector<BuildingId> m_buildings;
|
||||||
|
std::vector<entt::entity> m_actors;
|
||||||
|
std::vector<entt::entity> m_debris;
|
||||||
|
};
|
||||||
@@ -153,3 +153,13 @@ std::optional<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
|
|||||||
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels)
|
||||||
|
{
|
||||||
|
return [&tunnels](QPoint tile) -> std::optional<TunnelTileInfo>
|
||||||
|
{
|
||||||
|
const TunnelTileMap::const_iterator it = tunnels.find(tile);
|
||||||
|
if (it == tunnels.end()) { return std::nullopt; }
|
||||||
|
return it->second;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <map>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
@@ -9,6 +10,17 @@
|
|||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
|
|
||||||
|
// QPoint has no operator<, so an explicit ordering is needed to key a map or set
|
||||||
|
// by tile.
|
||||||
|
struct QPointCompare
|
||||||
|
{
|
||||||
|
bool operator()(const QPoint& a, const QPoint& b) const
|
||||||
|
{
|
||||||
|
if (a.x() != b.x()) { return a.x() < b.x(); }
|
||||||
|
return a.y() < b.y();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// A tunnel building occupying a single tile: whether it is an entry or an exit and
|
// A tunnel building occupying a single tile: whether it is an entry or an exit and
|
||||||
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
|
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
|
||||||
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
|
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
|
||||||
@@ -22,6 +34,13 @@ struct TunnelTileInfo
|
|||||||
// direction, or std::nullopt when the tile holds no tunnel building.
|
// direction, or std::nullopt when the tile holds no tunnel building.
|
||||||
using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>;
|
using TunnelLookup = std::function<std::optional<TunnelTileInfo>(QPoint)>;
|
||||||
|
|
||||||
|
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
|
||||||
|
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
|
||||||
|
|
||||||
|
// Wraps a tunnel tile index in the lookup functor the helpers below take. The
|
||||||
|
// returned functor references `tunnels`, which must outlive it.
|
||||||
|
TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
|
||||||
|
|
||||||
// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and
|
// Steps from `start` in `stepDir` over the tiles at distance 1..maxDistance and
|
||||||
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
|
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
|
||||||
// any other direction are skipped, mirroring the "stop at the first same-direction
|
// any other direction are skipped, mirroring the "stop at the first same-direction
|
||||||
|
|||||||
72
src/lib/core/WorldCamera.cpp
Normal file
72
src/lib/core/WorldCamera.cpp
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#include "WorldCamera.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped
|
||||||
|
// outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1.
|
||||||
|
float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x)
|
||||||
|
{
|
||||||
|
if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; }
|
||||||
|
const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0)));
|
||||||
|
return valueAt0 + (valueAt1 - valueAt0) * t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCamera::WorldCamera(const WorldScroll& scroll, const WorldRegions& regions)
|
||||||
|
: m_scroll(&scroll)
|
||||||
|
, m_regions(®ions)
|
||||||
|
, m_viewCenterXTiles(0.0f)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WorldCamera::advance(PanDirection direction, qint64 elapsedMs,
|
||||||
|
ScrollBounds bounds)
|
||||||
|
{
|
||||||
|
const float before = m_viewCenterXTiles;
|
||||||
|
|
||||||
|
if (direction != PanDirection::None)
|
||||||
|
{
|
||||||
|
const float distance =
|
||||||
|
getPanSpeedTilesPerSecondAt(m_viewCenterXTiles, bounds.rightTiles)
|
||||||
|
* static_cast<float>(elapsedMs) / 1000.0f;
|
||||||
|
m_viewCenterXTiles += (direction == PanDirection::Left) ? -distance : distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_viewCenterXTiles = std::max(bounds.leftTiles,
|
||||||
|
std::min(m_viewCenterXTiles, bounds.rightTiles));
|
||||||
|
return m_viewCenterXTiles != before;
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCamera::getViewCenterXTiles() const
|
||||||
|
{
|
||||||
|
return m_viewCenterXTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WorldCamera::reset()
|
||||||
|
{
|
||||||
|
m_viewCenterXTiles = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCamera::getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
|
||||||
|
float contestZoneRightEdgeTiles) const
|
||||||
|
{
|
||||||
|
// Slow near the asteroid/player buffer, fast across the contest zone, with a
|
||||||
|
// linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The
|
||||||
|
// contest zone spans from the player buffer's right edge to the enemy stations,
|
||||||
|
// the latter tracked live so the ramp follows the front line as it is pushed.
|
||||||
|
const float slow = static_cast<float>(m_scroll->panSpeedSlow_tps);
|
||||||
|
const float fast = static_cast<float>(m_scroll->panSpeedFast_tps);
|
||||||
|
const float half = static_cast<float>(m_scroll->panRampBandWidth_tiles) / 2.0f;
|
||||||
|
const float leftEdge = static_cast<float>(m_regions->playerBufferWidth_tiles);
|
||||||
|
const float rightEdge = contestZoneRightEdgeTiles;
|
||||||
|
|
||||||
|
// Rising ramp at the left boundary (slow -> fast) and falling ramp at the right
|
||||||
|
// boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in
|
||||||
|
// the middle, and — if the bands overlap in a narrow contest zone — a single peak
|
||||||
|
// below the fast speed where the two ramps cross.
|
||||||
|
const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles);
|
||||||
|
const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles);
|
||||||
|
return std::min(leftRamp, rightRamp);
|
||||||
|
}
|
||||||
72
src/lib/core/WorldCamera.h
Normal file
72
src/lib/core/WorldCamera.h
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QtGlobal>
|
||||||
|
|
||||||
|
#include "WorldConfig.h"
|
||||||
|
|
||||||
|
// Which way the player is currently panning the view (REQ-UI-SCROLL). A plain
|
||||||
|
// direction rather than key state: the camera is deliberately agnostic about how
|
||||||
|
// the intent was expressed, so rebindable controls would change nothing here.
|
||||||
|
enum class PanDirection
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Left,
|
||||||
|
Right
|
||||||
|
};
|
||||||
|
|
||||||
|
// The horizontal limits of the view center, in world tiles (REQ-GW-SCROLL-LIMIT):
|
||||||
|
// the view can pan left until the asteroid's left edge is centered and right until
|
||||||
|
// the enemy stations are. Both move as the game progresses — the left edge with
|
||||||
|
// asteroid expansion (REQ-GW-ASTEROID-EXPAND), the right edge as stations are
|
||||||
|
// pushed back (REQ-GW-PUSH-EXPAND) — so they are supplied per frame by the caller
|
||||||
|
// that can see the simulation, rather than queried here. That keeps the camera a
|
||||||
|
// value with no simulation dependency.
|
||||||
|
struct ScrollBounds
|
||||||
|
{
|
||||||
|
float leftTiles;
|
||||||
|
float rightTiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal view position for the game world (REQ-UI-SCROLL, REQ-UI-SCROLL-SPEED).
|
||||||
|
// Works purely in world units — tiles and tiles per second, never pixels. Turning
|
||||||
|
// the resulting position into a widget transform is WorldCoordinates' job; the two
|
||||||
|
// meet only where the view feeds getViewCenterXTiles() into that transform.
|
||||||
|
class WorldCamera
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// Both config structs are referenced rather than copied: they live inside the
|
||||||
|
// Simulation's GameConfig, which is assigned in place on restart
|
||||||
|
// (REQ-CFG-RELOAD), so a camera built once still picks up reloaded tuning.
|
||||||
|
WorldCamera(const WorldScroll& scroll, const WorldRegions& regions);
|
||||||
|
|
||||||
|
// Pans by `direction` for `elapsedMs` of wall-clock time, then clamps into
|
||||||
|
// `bounds`. Wall clock rather than ticks because panning is presentation only
|
||||||
|
// (REQ-UI-NO-ZOOM's sibling concern) and keeps working while the simulation is
|
||||||
|
// paused. Clamping happens on every call, not just when panning, so the view
|
||||||
|
// follows the bounds inward when they shrink.
|
||||||
|
//
|
||||||
|
// Returns true when the view center actually moved — including when it moved
|
||||||
|
// only because the bounds did. Callers use that to refresh anything anchored to
|
||||||
|
// the world under a stationary cursor, such as the box-select rectangle.
|
||||||
|
bool advance(PanDirection direction, qint64 elapsedMs, ScrollBounds bounds);
|
||||||
|
|
||||||
|
// World X (tiles) at the center of the viewport.
|
||||||
|
float getViewCenterXTiles() const;
|
||||||
|
|
||||||
|
// Returns the view to the start-of-run position. Deliberately does not clamp:
|
||||||
|
// a new run's bounds are not known here, and the next advance() clamps anyway.
|
||||||
|
void reset();
|
||||||
|
|
||||||
|
// Pan speed at a given view center, in tiles/s (REQ-UI-SCROLL-SPEED). Public
|
||||||
|
// because the ramp shape is the subtle part of this class and is worth testing
|
||||||
|
// directly; advance() uses it internally. `contestZoneRightEdgeTiles` is the
|
||||||
|
// live right-hand boundary — the same value as ScrollBounds::rightTiles — so
|
||||||
|
// the ramp follows the front line as it is pushed.
|
||||||
|
float getPanSpeedTilesPerSecondAt(float viewCenterXTiles,
|
||||||
|
float contestZoneRightEdgeTiles) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const WorldScroll* m_scroll;
|
||||||
|
const WorldRegions* m_regions;
|
||||||
|
float m_viewCenterXTiles;
|
||||||
|
};
|
||||||
118
src/lib/core/WorldCoordinates.cpp
Normal file
118
src/lib/core/WorldCoordinates.cpp
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// A zero-height widget, a not-yet-shown widget, or a degenerate world size
|
||||||
|
// would otherwise make every conversion divide by zero.
|
||||||
|
float sanitizeTilePx(float tilePx)
|
||||||
|
{
|
||||||
|
return tilePx > 0.0f ? tilePx : 1.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates WorldCoordinates::scrolling(QSize widgetSize_px,
|
||||||
|
int worldHeight_tiles,
|
||||||
|
float viewCenterX_tiles)
|
||||||
|
{
|
||||||
|
float tilePx = 1.0f;
|
||||||
|
if (worldHeight_tiles > 0)
|
||||||
|
{
|
||||||
|
tilePx = static_cast<float>(widgetSize_px.height())
|
||||||
|
/ static_cast<float>(worldHeight_tiles);
|
||||||
|
}
|
||||||
|
tilePx = sanitizeTilePx(tilePx);
|
||||||
|
|
||||||
|
const float viewportWidthTiles = static_cast<float>(widgetSize_px.width()) / tilePx;
|
||||||
|
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
|
||||||
|
viewCenterX_tiles - viewportWidthTiles / 2.0f,
|
||||||
|
worldHeight_tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates WorldCoordinates::fitToWorld(QSize widgetSize_px,
|
||||||
|
int worldWidth_tiles,
|
||||||
|
int worldHeight_tiles)
|
||||||
|
{
|
||||||
|
float tilePx = 1.0f;
|
||||||
|
if (worldWidth_tiles > 0 && worldHeight_tiles > 0)
|
||||||
|
{
|
||||||
|
// The tighter of the two fits, so the whole world stays on screen.
|
||||||
|
tilePx = std::min(
|
||||||
|
static_cast<float>(widgetSize_px.height()) / static_cast<float>(worldHeight_tiles),
|
||||||
|
static_cast<float>(widgetSize_px.width()) / static_cast<float>(worldWidth_tiles));
|
||||||
|
}
|
||||||
|
tilePx = sanitizeTilePx(tilePx);
|
||||||
|
|
||||||
|
return WorldCoordinates(tilePx, static_cast<float>(widgetSize_px.width()),
|
||||||
|
0.0f, worldHeight_tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldCoordinates::WorldCoordinates(float tilePx, float viewportWidth_px,
|
||||||
|
float viewLeft_tiles, int worldHeight_tiles)
|
||||||
|
: m_tilePx(tilePx)
|
||||||
|
, m_viewportWidthTiles(viewportWidth_px / tilePx)
|
||||||
|
, m_viewLeftTiles(viewLeft_tiles)
|
||||||
|
, m_worldHeightTiles(worldHeight_tiles)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCoordinates::getTilePx() const
|
||||||
|
{
|
||||||
|
return m_tilePx;
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCoordinates::getViewportWidthTiles() const
|
||||||
|
{
|
||||||
|
return m_viewportWidthTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
float WorldCoordinates::getViewLeftTiles() const
|
||||||
|
{
|
||||||
|
return m_viewLeftTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
QPointF WorldCoordinates::worldToWidget(QVector2D worldPos) const
|
||||||
|
{
|
||||||
|
return QPointF(
|
||||||
|
static_cast<qreal>((worldPos.x() - m_viewLeftTiles) * m_tilePx),
|
||||||
|
static_cast<qreal>(worldPos.y() * m_tilePx));
|
||||||
|
}
|
||||||
|
|
||||||
|
QPointF WorldCoordinates::tileToWidget(QPoint tile) const
|
||||||
|
{
|
||||||
|
return worldToWidget(QVector2D(static_cast<float>(tile.x()),
|
||||||
|
static_cast<float>(tile.y())));
|
||||||
|
}
|
||||||
|
|
||||||
|
QPoint WorldCoordinates::widgetToTile(QPoint widgetPoint) const
|
||||||
|
{
|
||||||
|
const QVector2D world = widgetToWorld(widgetPoint);
|
||||||
|
return QPoint(static_cast<int>(std::floor(world.x())),
|
||||||
|
static_cast<int>(std::floor(world.y())));
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector2D WorldCoordinates::widgetToWorld(QPoint widgetPoint) const
|
||||||
|
{
|
||||||
|
return QVector2D(
|
||||||
|
static_cast<float>(widgetPoint.x()) / m_tilePx + m_viewLeftTiles,
|
||||||
|
static_cast<float>(widgetPoint.y()) / m_tilePx);
|
||||||
|
}
|
||||||
|
|
||||||
|
QRectF WorldCoordinates::tileRect(QPoint tile) const
|
||||||
|
{
|
||||||
|
const QPointF topLeft = tileToWidget(tile);
|
||||||
|
return QRectF(topLeft.x(), topLeft.y(),
|
||||||
|
static_cast<qreal>(m_tilePx), static_cast<qreal>(m_tilePx));
|
||||||
|
}
|
||||||
|
|
||||||
|
QRect WorldCoordinates::getViewportRect() const
|
||||||
|
{
|
||||||
|
const int left = static_cast<int>(std::floor(m_viewLeftTiles)) - 1;
|
||||||
|
const int top = 0;
|
||||||
|
const int right = static_cast<int>(
|
||||||
|
std::ceil(m_viewLeftTiles + m_viewportWidthTiles)) + 1;
|
||||||
|
const int bottom = m_worldHeightTiles;
|
||||||
|
return QRect(left, top, right - left, bottom - top);
|
||||||
|
}
|
||||||
63
src/lib/core/WorldCoordinates.h
Normal file
63
src/lib/core/WorldCoordinates.h
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QSize>
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
// Immutable snapshot of the world <-> widget transform for one viewport state
|
||||||
|
// (REQ-GW-COORDS). Tiles are square; the two factories below differ only in how
|
||||||
|
// the tile size and the left edge are derived, and everything downstream of that
|
||||||
|
// is shared.
|
||||||
|
//
|
||||||
|
// The transform is a value: it is constructed from the viewport size and the view
|
||||||
|
// state, and never observes them again. A caller therefore builds one per frame
|
||||||
|
// (or per event) rather than holding one across a resize or a scroll, which would
|
||||||
|
// silently go stale.
|
||||||
|
class WorldCoordinates
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// The scrolling game world: the tile size is whatever makes the world height
|
||||||
|
// exactly fill the viewport height (REQ-GW-TILE-SIZE, no zoom per
|
||||||
|
// REQ-UI-NO-ZOOM), and the view pans horizontally. `viewCenterX_tiles` is the
|
||||||
|
// world X at the center of the viewport, matching how the scroll position is
|
||||||
|
// stored and clamped (REQ-GW-SCROLL-LIMIT).
|
||||||
|
static WorldCoordinates scrolling(QSize widgetSize_px, int worldHeight_tiles,
|
||||||
|
float viewCenterX_tiles);
|
||||||
|
|
||||||
|
// A whole world shown at once with no scrolling, as the balancing tool's arena
|
||||||
|
// does: the tile size is whichever axis is the tighter fit, so nothing is cut
|
||||||
|
// off, and the world origin sits at the widget's top-left. A viewport wider
|
||||||
|
// than the fitted world leaves empty space to the right rather than centering.
|
||||||
|
static WorldCoordinates fitToWorld(QSize widgetSize_px, int worldWidth_tiles,
|
||||||
|
int worldHeight_tiles);
|
||||||
|
|
||||||
|
// Side length of one tile in pixels. Always positive: a degenerate world or
|
||||||
|
// viewport size falls back to 1.0 so no conversion below divides by zero.
|
||||||
|
float getTilePx() const;
|
||||||
|
float getViewportWidthTiles() const;
|
||||||
|
// World X (tiles) at the left edge of the viewport.
|
||||||
|
float getViewLeftTiles() const;
|
||||||
|
|
||||||
|
QPointF worldToWidget(QVector2D worldPos) const;
|
||||||
|
QPointF tileToWidget(QPoint tile) const;
|
||||||
|
QPoint widgetToTile(QPoint widgetPoint) const;
|
||||||
|
QVector2D widgetToWorld(QPoint widgetPoint) const;
|
||||||
|
// Widget-space rect covering the whole of `tile`.
|
||||||
|
QRectF tileRect(QPoint tile) const;
|
||||||
|
|
||||||
|
// Tile-space rect of everything currently on screen, widened by one column on
|
||||||
|
// each side so items straddling an edge are still drawn.
|
||||||
|
QRect getViewportRect() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
WorldCoordinates(float tilePx, float viewportWidth_px, float viewLeft_tiles,
|
||||||
|
int worldHeight_tiles);
|
||||||
|
|
||||||
|
float m_tilePx;
|
||||||
|
float m_viewportWidthTiles;
|
||||||
|
float m_viewLeftTiles;
|
||||||
|
int m_worldHeightTiles;
|
||||||
|
};
|
||||||
11
src/lib/eventsystem/event/BlueprintSaveRequestedEvent.h
Normal file
11
src/lib/eventsystem/event/BlueprintSaveRequestedEvent.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Ctrl+C: save the current building selection as a named blueprint
|
||||||
|
// (REQ-UI-BLUEPRINT-CREATE, REQ-UI-HOTKEYS). MainWindow owns the modal flow, because
|
||||||
|
// it is the only widget that can pause the game and raise the dim overlay; whether
|
||||||
|
// anything placeable is selected is decided there, not by the key handler.
|
||||||
|
class BlueprintSaveRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
11
src/lib/eventsystem/event/BlueprintSelectionRequestedEvent.h
Normal file
11
src/lib/eventsystem/event/BlueprintSelectionRequestedEvent.h
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Ctrl+V: open the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG,
|
||||||
|
// REQ-UI-HOTKEYS). The Ctrl+C path does not go through this event -- it opens the
|
||||||
|
// dialog directly so one pause scope and one dim scope span both dialogs
|
||||||
|
// (REQ-UI-MODAL-DIM).
|
||||||
|
class BlueprintSelectionRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
@@ -10,12 +10,21 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoicesAvailableEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectionChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/GameResetEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSaveRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/SpeedStepRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/GhostRotationRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ModeCancelRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggleRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildingTypeSelectedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h
|
||||||
@@ -23,7 +32,8 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeToggleRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructModeToggleRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPlacementRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ExitBlueprintModeRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintCaptureRequestedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/TemporaryBlueprintPlaceRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SpeedChangeRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/LayoutDialogRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionRequestedEvent.h
|
||||||
|
|||||||
12
src/lib/eventsystem/event/DebugDrawToggleRequestedEvent.h
Normal file
12
src/lib/eventsystem/event/DebugDrawToggleRequestedEvent.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player asked to toggle the debug overlays (REQ-UI-HOTKEYS). The request to
|
||||||
|
// flip the flag; DebugDrawToggledEvent is the announcement that it was flipped and
|
||||||
|
// what it now is. Splitting the two keeps the flag itself in one owner.
|
||||||
|
class DebugDrawToggleRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
DebugDrawToggleRequestedEvent() = default;
|
||||||
|
};
|
||||||
13
src/lib/eventsystem/event/GameResetEvent.h
Normal file
13
src/lib/eventsystem/event/GameResetEvent.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Emitted once per restart, after a queued ResetCommand has been applied and the view has
|
||||||
|
// been reset for the new run (REQ-UI-GAME-MENU). It lets presentation state that does not
|
||||||
|
// live in the simulation -- e.g. the temporary blueprint, REQ-UI-BLUEPRINT-TEMP -- be
|
||||||
|
// dropped for the fresh run. Sent from GameWorldView::resetForNewGame(), which is where a
|
||||||
|
// restart actually lands: the escape menu, game over, and win dialogs only enqueue the
|
||||||
|
// command.
|
||||||
|
class GameResetEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
13
src/lib/eventsystem/event/GhostRotationRequestedEvent.h
Normal file
13
src/lib/eventsystem/event/GhostRotationRequestedEvent.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player asked to rotate the placement ghost (REQ-BLD-ROTATE, REQ-UI-HOTKEYS).
|
||||||
|
// Sent whether or not a builder or blueprint mode is actually active; deciding
|
||||||
|
// there is nothing to rotate is the receiver's job.
|
||||||
|
class GhostRotationRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit GhostRotationRequestedEvent(bool clockwise) : clockwise(clockwise) {}
|
||||||
|
const bool clockwise;
|
||||||
|
};
|
||||||
13
src/lib/eventsystem/event/ModeCancelRequestedEvent.h
Normal file
13
src/lib/eventsystem/event/ModeCancelRequestedEvent.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player pressed the one "get me out of the current mode" key (REQ-UI-HOTKEYS).
|
||||||
|
// Intentionally says only that, not which mode to leave: which of builder,
|
||||||
|
// blueprint placement, or deconstruct is active — and that the key falls through to
|
||||||
|
// entering deconstruct mode when none of them is — is state only the receiver has.
|
||||||
|
class ModeCancelRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ModeCancelRequestedEvent() = default;
|
||||||
|
};
|
||||||
22
src/lib/eventsystem/event/PanDirectionChangedEvent.h
Normal file
22
src/lib/eventsystem/event/PanDirectionChangedEvent.h
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
#include "WorldCamera.h"
|
||||||
|
|
||||||
|
// The direction the player is currently panning the view has changed
|
||||||
|
// (REQ-UI-SCROLL). Deliberately level-triggered: the payload is the complete
|
||||||
|
// current direction, including PanDirection::None when panning stops, rather than
|
||||||
|
// separate started/stopped events. A receiver that only ever saw edges would have
|
||||||
|
// to reconstruct the state and would be left panning forever if one edge went
|
||||||
|
// missing — which is exactly what happens when the widget loses focus mid-pan.
|
||||||
|
//
|
||||||
|
// Unlike the state-change events elsewhere in the UI, the receiver does cache this
|
||||||
|
// payload instead of re-reading the value from somewhere authoritative. That is
|
||||||
|
// correct here: input has no other source of truth to re-read from, so the
|
||||||
|
// publisher (InputMapper) is the authority and the payload is the value.
|
||||||
|
class PanDirectionChangedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit PanDirectionChangedEvent(PanDirection direction) : direction(direction) {}
|
||||||
|
const PanDirection direction;
|
||||||
|
};
|
||||||
12
src/lib/eventsystem/event/PauseToggleRequestedEvent.h
Normal file
12
src/lib/eventsystem/event/PauseToggleRequestedEvent.h
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player asked to pause or unpause (REQ-UI-HOTKEYS). Carries no speed: which
|
||||||
|
// speed to restore on unpause is the receiver's business, since it is the one that
|
||||||
|
// remembers what was running before the pause.
|
||||||
|
class PauseToggleRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
PauseToggleRequestedEvent() = default;
|
||||||
|
};
|
||||||
14
src/lib/eventsystem/event/SpeedStepRequestedEvent.h
Normal file
14
src/lib/eventsystem/event/SpeedStepRequestedEvent.h
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// The player asked to step the game speed one notch (REQ-UI-HOTKEYS): +1 faster,
|
||||||
|
// -1 slower. A relative step rather than a target speed, because the ladder of
|
||||||
|
// available speeds belongs to the receiver — contrast SpeedChangeRequestedEvent,
|
||||||
|
// which names an absolute multiplier and is what the speed buttons send.
|
||||||
|
class SpeedStepRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit SpeedStepRequestedEvent(int delta) : delta(delta) {}
|
||||||
|
const int delta;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Emitted when the player presses C to capture a temporary blueprint (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
// Carries no payload: the blueprint is built from the current selection by the receiver.
|
||||||
|
class TemporaryBlueprintCaptureRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "Event.h"
|
||||||
|
|
||||||
|
// Emitted when the player presses V to re-enter placement mode for the temporary blueprint
|
||||||
|
// captured with C (REQ-UI-BLUEPRINT-TEMP). Carries no payload: the receiver holds the
|
||||||
|
// blueprint, and nothing is captured from the current selection.
|
||||||
|
class TemporaryBlueprintPlaceRequestedEvent : public Event
|
||||||
|
{
|
||||||
|
};
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include "Event.h"
|
|
||||||
|
|
||||||
// Emitted when the player presses the temporary-blueprint hotkey (REQ-UI-BLUEPRINT-TEMP).
|
|
||||||
// Carries no payload: the blueprint is built from the current selection by the receiver.
|
|
||||||
class TemporaryBlueprintRequestedEvent : public Event
|
|
||||||
{
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING),
|
// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING),
|
||||||
// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP)
|
// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP)
|
||||||
// or on Restart. The build button grid re-evaluates which buttons are shown.
|
// or on Restart. The build button bar re-evaluates which buttons are shown.
|
||||||
class UnlockedBuildingsChangedEvent : public Event
|
class UnlockedBuildingsChangedEvent : public Event
|
||||||
{
|
{
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <climits>
|
#include <climits>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
#include "BeltSystem.h"
|
#include "BeltSystem.h"
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
|
#include "BuildingsConfig.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
|
#include "DisplayName.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
@@ -49,6 +53,21 @@ std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, Building
|
|||||||
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
|
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Position of a building type in buildings.toml, which is the order the build button
|
||||||
|
// bar lays out its buttons (REQ-UI-BUILD-BAR) and so the tie-break order for a
|
||||||
|
// blueprint's contents line. A type with no config entry sorts after every known one.
|
||||||
|
std::size_t configOrderIndex(BuildingType type, const BuildingsConfig& buildings)
|
||||||
|
{
|
||||||
|
for (std::size_t i = 0; i < buildings.buildings.size(); ++i)
|
||||||
|
{
|
||||||
|
if (buildings.buildings[i].type == type)
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildings.buildings.size();
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
|
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
|
||||||
@@ -138,9 +157,9 @@ Blueprint captureBlueprintFromSelection(const Simulation& sim,
|
|||||||
building.type = e.type;
|
building.type = e.type;
|
||||||
building.rotation = e.rotation;
|
building.rotation = e.rotation;
|
||||||
building.offset = e.anchor - center;
|
building.offset = e.anchor - center;
|
||||||
// Recipe / schematic / layout / splitter-filter capture is shared with the
|
// Recipe / schematic / layout / splitter-filter capture goes through
|
||||||
// copy-settings gesture (REQ-BLD-COPY-CONFIG) via readBuildingConfig, which
|
// readBuildingConfig, which handles operational buildings and construction
|
||||||
// handles operational buildings and construction sites alike.
|
// sites alike.
|
||||||
const std::optional<BuildingConfig> config = readBuildingConfig(sim, e.id);
|
const std::optional<BuildingConfig> config = readBuildingConfig(sim, e.id);
|
||||||
if (config.has_value())
|
if (config.has_value())
|
||||||
{
|
{
|
||||||
@@ -166,3 +185,56 @@ bool selectionHasPlaceableBuilding(const Simulation& sim,
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
|
||||||
|
const Blueprint& blueprint, const BuildingsConfig& buildings)
|
||||||
|
{
|
||||||
|
std::map<BuildingType, int> counts;
|
||||||
|
for (const BlueprintBuilding& building : blueprint.buildings)
|
||||||
|
{
|
||||||
|
++counts[building.type];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The config index is carried through the sort so the comparator stays a strict
|
||||||
|
// total order; the enum value is the final tie-break, which only two config-less
|
||||||
|
// types could ever reach.
|
||||||
|
struct RankedType
|
||||||
|
{
|
||||||
|
BuildingType type;
|
||||||
|
int count;
|
||||||
|
std::size_t order;
|
||||||
|
};
|
||||||
|
std::vector<RankedType> ranked;
|
||||||
|
ranked.reserve(counts.size());
|
||||||
|
for (const std::pair<const BuildingType, int>& entry : counts)
|
||||||
|
{
|
||||||
|
ranked.push_back({entry.first, entry.second,
|
||||||
|
configOrderIndex(entry.first, buildings)});
|
||||||
|
}
|
||||||
|
std::sort(ranked.begin(), ranked.end(),
|
||||||
|
[](const RankedType& left, const RankedType& right)
|
||||||
|
{
|
||||||
|
if (left.count != right.count) { return left.count > right.count; }
|
||||||
|
if (left.order != right.order) { return left.order < right.order; }
|
||||||
|
return static_cast<int>(left.type) < static_cast<int>(right.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
std::vector<BlueprintContentEntry> summary;
|
||||||
|
summary.reserve(ranked.size());
|
||||||
|
for (const RankedType& entry : ranked)
|
||||||
|
{
|
||||||
|
summary.push_back({toDisplayName(buildingTypeId(entry.type)), entry.count});
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
for (const BlueprintBuilding& building : blueprint.buildings)
|
||||||
|
{
|
||||||
|
const BuildingDef* def = buildings.findBuildingDef(building.type);
|
||||||
|
if (def) { total += def->cost; }
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,11 +11,12 @@
|
|||||||
#include "ShipLayout.h"
|
#include "ShipLayout.h"
|
||||||
|
|
||||||
class Simulation;
|
class Simulation;
|
||||||
|
struct BuildingsConfig;
|
||||||
|
|
||||||
// The user-configurable settings of a single building or construction site: the
|
// The user-configurable settings of a single building or construction site: the
|
||||||
// selected recipe / ship schematic, the shipyard module layout, and (for
|
// selected recipe / ship schematic, the shipyard module layout, and (for
|
||||||
// splitters) the two output filters. Shared by the copy-settings gesture
|
// splitters) the two output filters. This is what blueprint capture records per
|
||||||
// (REQ-BLD-COPY-CONFIG) and blueprint capture (REQ-UI-BLUEPRINT-STORAGE).
|
// constituent building (REQ-UI-BLUEPRINT-STORAGE).
|
||||||
struct BuildingConfig
|
struct BuildingConfig
|
||||||
{
|
{
|
||||||
BuildingType type = BuildingType::Miner;
|
BuildingType type = BuildingType::Miner;
|
||||||
@@ -49,6 +50,29 @@ Blueprint captureBlueprintFromSelection(const Simulation& sim,
|
|||||||
const std::vector<BuildingId>& selectedIds);
|
const std::vector<BuildingId>& selectedIds);
|
||||||
|
|
||||||
// True if any selected id refers to a player-placeable building or construction site
|
// True if any selected id refers to a player-placeable building or construction site
|
||||||
// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE).
|
// (the condition under which Ctrl+C opens the blueprint save dialog,
|
||||||
|
// REQ-UI-BLUEPRINT-CREATE).
|
||||||
bool selectionHasPlaceableBuilding(const Simulation& sim,
|
bool selectionHasPlaceableBuilding(const Simulation& sim,
|
||||||
const std::vector<BuildingId>& selectedIds);
|
const std::vector<BuildingId>& selectedIds);
|
||||||
|
|
||||||
|
// One "<building name> x <count>" entry of a blueprint card's contents line
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
struct BlueprintContentEntry
|
||||||
|
{
|
||||||
|
std::string buildingName;
|
||||||
|
int count;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Summarizes what a blueprint holds: one entry per building type it contains, ordered
|
||||||
|
// by descending count with ties broken by the order the types appear in buildings.toml
|
||||||
|
// (which is the order of the build button bar, REQ-UI-BUILD-BAR). Building types absent
|
||||||
|
// from the config sort last. Derived display data for the blueprint card, kept here so
|
||||||
|
// it is unit-testable rather than buried in the dialog (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
|
||||||
|
const Blueprint& blueprint, const BuildingsConfig& buildings);
|
||||||
|
|
||||||
|
// Plain sum of the placement cost of every building in the blueprint
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD). Distinct from the total charged on placement
|
||||||
|
// (REQ-UI-BLUEPRINT-PLACE), which additionally excludes locked building types and
|
||||||
|
// rotate-in-place targets; see GameWorldView's placement path.
|
||||||
|
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings);
|
||||||
|
|||||||
@@ -284,6 +284,21 @@ void BuildingSystem::setShipLayout(FactoryState& state, BuildingId id, const Shi
|
|||||||
{
|
{
|
||||||
if (building.id == id)
|
if (building.id == id)
|
||||||
{
|
{
|
||||||
|
// No-op if the layout is unchanged, so re-applying the layout a shipyard
|
||||||
|
// already has does not cancel its production cycle or wipe its buffers
|
||||||
|
// (REQ-MAT-INPUT-BUFFER, REQ-BLD-SHIPYARD). Confirming the layout dialog
|
||||||
|
// without editing anything, and a blueprint configuration transfer onto an
|
||||||
|
// already-matching shipyard (REQ-UI-BLUEPRINT-TRANSFER), both land here.
|
||||||
|
// An unset layout counts as an empty one: the two are equivalent for
|
||||||
|
// buffers, production, and the spawned ship (see the spawn path below),
|
||||||
|
// so an empty layout arriving at an unconfigured shipyard changes nothing.
|
||||||
|
const bool unchanged = building.shipLayout.has_value()
|
||||||
|
? *building.shipLayout == layout
|
||||||
|
: layout.placedModules.empty();
|
||||||
|
if (unchanged)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (building.production.has_value())
|
if (building.production.has_value())
|
||||||
{
|
{
|
||||||
building.production = std::nullopt;
|
building.production = std::nullopt;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "FactoryQueries.h"
|
#include "FactoryQueries.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
|
||||||
#include "PortGeometry.h"
|
#include "PortGeometry.h"
|
||||||
@@ -179,3 +180,61 @@ getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, Buildin
|
|||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
|
||||||
|
QPoint cornerA, QPoint cornerB)
|
||||||
|
{
|
||||||
|
const int x0 = std::min(cornerA.x(), cornerB.x());
|
||||||
|
const int y0 = std::min(cornerA.y(), cornerB.y());
|
||||||
|
const int x1 = std::max(cornerA.x(), cornerB.x());
|
||||||
|
const int y1 = std::max(cornerA.y(), cornerB.y());
|
||||||
|
|
||||||
|
const auto covers = [&](const std::vector<QPoint>& bodyCells)
|
||||||
|
{
|
||||||
|
for (const QPoint& cell : bodyCells)
|
||||||
|
{
|
||||||
|
if (cell.x() >= x0 && cell.x() <= x1
|
||||||
|
&& cell.y() >= y0 && cell.y() <= y1)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<BuildingId> ids;
|
||||||
|
for (const Building& building : getAllBuildings(state))
|
||||||
|
{
|
||||||
|
if (covers(building.bodyCells)) { ids.push_back(building.id); }
|
||||||
|
}
|
||||||
|
for (const ConstructionSite& site : getAllSites(state))
|
||||||
|
{
|
||||||
|
if (covers(site.bodyCells)) { ids.push_back(site.id); }
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
TunnelTileMap collectTunnelTiles(const FactoryState& state)
|
||||||
|
{
|
||||||
|
// Index every tunnel entry/exit — built or still a construction site — by its
|
||||||
|
// single-cell tile, so a just-placed tunnel (not yet constructed) is matchable
|
||||||
|
// (REQ-BLD-TUNNEL-MODE, REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
|
||||||
|
TunnelTileMap tunnels;
|
||||||
|
for (const Building& building : getAllBuildings(state))
|
||||||
|
{
|
||||||
|
if (building.type == BuildingType::TunnelEntry
|
||||||
|
|| building.type == BuildingType::TunnelExit)
|
||||||
|
{
|
||||||
|
tunnels[building.anchor] = TunnelTileInfo{building.type, building.rotation};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const ConstructionSite& site : getAllSites(state))
|
||||||
|
{
|
||||||
|
if (site.type == BuildingType::TunnelEntry
|
||||||
|
|| site.type == BuildingType::TunnelExit)
|
||||||
|
{
|
||||||
|
tunnels[site.anchor] = TunnelTileInfo{site.type, site.rotation};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tunnels;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include "FactoryState.h"
|
#include "FactoryState.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
#include "Port.h"
|
#include "Port.h"
|
||||||
|
#include "TunnelCompletion.h"
|
||||||
|
|
||||||
// Queries and operations over the factory's world data that need nothing but that
|
// Queries and operations over the factory's world data that need nothing but that
|
||||||
// data — no config, no belts, no RNG. Free functions rather than BuildingSystem
|
// data — no config, no belts, no RNG. Free functions rather than BuildingSystem
|
||||||
@@ -69,3 +70,13 @@ std::vector<Port> getInputPorts(const FactoryState& state, const GameConfig& con
|
|||||||
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState& state,
|
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState& state,
|
||||||
const GameConfig& config,
|
const GameConfig& config,
|
||||||
BuildingId id);
|
BuildingId id);
|
||||||
|
|
||||||
|
// Ids of all buildings and construction sites whose footprint intersects the tile
|
||||||
|
// box spanned by the two (unordered) corner tiles (REQ-UI-MULTI-SELECT,
|
||||||
|
// REQ-BLD-DECONSTRUCT-BOX).
|
||||||
|
std::vector<BuildingId> buildingsInBox(const FactoryState& state,
|
||||||
|
QPoint cornerA, QPoint cornerB);
|
||||||
|
|
||||||
|
// Every tunnel entry and exit, built or still a construction site, indexed by its
|
||||||
|
// single-cell tile. Shared by the placement preview and the selection highlight.
|
||||||
|
TunnelTileMap collectTunnelTiles(const FactoryState& state);
|
||||||
|
|||||||
@@ -69,16 +69,9 @@ bool isPlacementValid(const FactoryState& state, const GameConfig& config,Buildi
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config,
|
std::optional<CoincidingBuilding> findCoincidingSameTypeBuilding(const FactoryState& state,
|
||||||
BuildingType type, QPoint anchor, Rotation rot)
|
const GameConfig& config, BuildingType type, QPoint anchor, Rotation rot)
|
||||||
{
|
{
|
||||||
// Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a
|
|
||||||
// tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE).
|
|
||||||
if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit)
|
|
||||||
{
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BuildingDef* def = config.buildings.findBuildingDef(type);
|
const BuildingDef* def = config.buildings.findBuildingDef(type);
|
||||||
if (!def) { return std::nullopt; }
|
if (!def) { return std::nullopt; }
|
||||||
|
|
||||||
@@ -106,16 +99,213 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, con
|
|||||||
if (site.id != candidateId) { continue; }
|
if (site.id != candidateId) { continue; }
|
||||||
if (site.type != type) { return std::nullopt; }
|
if (site.type != type) { return std::nullopt; }
|
||||||
if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
|
if (site.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
|
||||||
return candidateId;
|
return CoincidingBuilding{candidateId, site.rotation};
|
||||||
}
|
}
|
||||||
for (const Building& b : state.buildings)
|
for (const Building& b : state.buildings)
|
||||||
{
|
{
|
||||||
if (b.id != candidateId) { continue; }
|
if (b.id != candidateId) { continue; }
|
||||||
if (b.type != type) { return std::nullopt; }
|
if (b.type != type) { return std::nullopt; }
|
||||||
if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
|
if (b.bodyCells.size() != mask.bodyCells.size()) { return std::nullopt; }
|
||||||
return candidateId;
|
return CoincidingBuilding{candidateId, b.rotation};
|
||||||
}
|
}
|
||||||
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, const GameConfig& config,
|
||||||
|
BuildingType type, QPoint anchor, Rotation rot)
|
||||||
|
{
|
||||||
|
// Tunnel Entries and Tunnel Exits cannot be rotated in place; re-orienting a
|
||||||
|
// tunnel requires deconstructing and re-placing it (REQ-BLD-ROTATE-IN-PLACE).
|
||||||
|
if (type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit)
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::optional<CoincidingBuilding> target =
|
||||||
|
findCoincidingSameTypeBuilding(state, config, type, anchor, rot);
|
||||||
|
if (!target.has_value()) { return std::nullopt; }
|
||||||
|
return target->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// The building or construction site occupying `tile`, if it is of `type`, together with
|
||||||
|
// where it stands. The single-building transfer gesture hit-tests the cursor with this
|
||||||
|
// instead of comparing footprints (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
std::optional<CoincidingBuilding> findSameTypeBuildingAt(const FactoryState& state,
|
||||||
|
BuildingType type, QPoint tile,
|
||||||
|
QPoint& anchorOut)
|
||||||
|
{
|
||||||
|
const std::optional<BuildingId> owner = state.grid.findOwner(tile);
|
||||||
|
if (!owner.has_value()) { return std::nullopt; }
|
||||||
|
|
||||||
|
if (const ConstructionSite* site = findSite(state, *owner))
|
||||||
|
{
|
||||||
|
if (site->type != type) { return std::nullopt; }
|
||||||
|
anchorOut = site->anchor;
|
||||||
|
return CoincidingBuilding{site->id, site->rotation};
|
||||||
|
}
|
||||||
|
if (const Building* building = findBuilding(state, *owner))
|
||||||
|
{
|
||||||
|
if (building->type != type) { return std::nullopt; }
|
||||||
|
anchorOut = building->anchor;
|
||||||
|
return CoincidingBuilding{building->id, building->rotation};
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state, const GameConfig& config,
|
||||||
|
BuildingType type, QPoint anchor, Rotation rotation, std::optional<QPoint> hoverTile)
|
||||||
|
{
|
||||||
|
// The single-building gesture first, because it answers without looking at the
|
||||||
|
// ghost's own position at all: whatever same-type building the cursor is on takes the
|
||||||
|
// settings, at any facing, and the ghost snaps onto it (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
if (hoverTile.has_value() && isConfigurableBuildingType(type))
|
||||||
|
{
|
||||||
|
QPoint hoveredAnchor;
|
||||||
|
const std::optional<CoincidingBuilding> hovered =
|
||||||
|
findSameTypeBuildingAt(state, type, *hoverTile, hoveredAnchor);
|
||||||
|
if (hovered.has_value())
|
||||||
|
{
|
||||||
|
return BlueprintGhostResolved{BlueprintGhostAction::Transfer, hovered->id,
|
||||||
|
hoveredAnchor, hovered->rotation};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terrain and world bounds next: nothing rescues a ghost hanging off the asteroid
|
||||||
|
// (REQ-BLD-PLACE-VALID condition (a)).
|
||||||
|
if (!isPlacementValid(state, config, type, anchor, rotation))
|
||||||
|
{
|
||||||
|
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt,
|
||||||
|
anchor, rotation};
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::optional<CoincidingBuilding> coinciding =
|
||||||
|
findCoincidingSameTypeBuilding(state, config, type, anchor, rotation);
|
||||||
|
if (coinciding.has_value())
|
||||||
|
{
|
||||||
|
// The building the blueprint wants is already there, facing the same way. It
|
||||||
|
// takes the blueprint's settings if it has any to take (REQ-UI-BLUEPRINT-TRANSFER)
|
||||||
|
// and is otherwise left exactly as it is (REQ-UI-BLUEPRINT-OVERLAP). Tunnels are
|
||||||
|
// not excluded from the latter: nothing is rotated, so the reason for their
|
||||||
|
// REQ-BLD-ROTATE-IN-PLACE exception does not arise.
|
||||||
|
if (coinciding->rotation == rotation)
|
||||||
|
{
|
||||||
|
return BlueprintGhostResolved{isConfigurableBuildingType(type)
|
||||||
|
? BlueprintGhostAction::Transfer
|
||||||
|
: BlueprintGhostAction::CompatibleOverlap,
|
||||||
|
coinciding->id, anchor, rotation};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facing the other way, and placement may not re-orient it.
|
||||||
|
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt,
|
||||||
|
anchor, rotation};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No coincidence: any occupancy at all is an ordinary overlap
|
||||||
|
// (REQ-BLD-PLACE-VALID condition (b)).
|
||||||
|
const BuildingDef* def = config.buildings.findBuildingDef(type);
|
||||||
|
if (!def)
|
||||||
|
{
|
||||||
|
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt,
|
||||||
|
anchor, rotation};
|
||||||
|
}
|
||||||
|
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
|
||||||
|
for (const QPoint& relativeCell : parsed.bodyCells)
|
||||||
|
{
|
||||||
|
if (isTileOccupied(state, anchor + relativeCell))
|
||||||
|
{
|
||||||
|
return BlueprintGhostResolved{BlueprintGhostAction::Invalid, std::nullopt,
|
||||||
|
anchor, rotation};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BlueprintGhostResolved{BlueprintGhostAction::PlaceNew, std::nullopt,
|
||||||
|
anchor, rotation};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
|
||||||
|
BuildingType type, QPoint anchor, Rotation rotation)
|
||||||
|
{
|
||||||
|
// Terrain and world-bounds validity first (REQ-BLD-PLACE-VALID); occupancy is
|
||||||
|
// the extra rule this adds.
|
||||||
|
if (!isPlacementValid(state, config, type, anchor, rotation))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BuildingDef* def = config.buildings.findBuildingDef(type);
|
||||||
|
if (!def) { return false; }
|
||||||
|
const ParsedSurfaceMask parsed = parseSurfaceMask(def->surfaceMask, rotation);
|
||||||
|
|
||||||
|
bool anyOccupied = false;
|
||||||
|
for (const QPoint& relativeCell : parsed.bodyCells)
|
||||||
|
{
|
||||||
|
if (isTileOccupied(state, anchor + relativeCell))
|
||||||
|
{
|
||||||
|
anyOccupied = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyOccupied)
|
||||||
|
{
|
||||||
|
// Occupied is still placeable when what is there is the same building being
|
||||||
|
// re-oriented (REQ-BLD-ROTATE-IN-PLACE).
|
||||||
|
return findRotateInPlaceTarget(state, config, type, anchor, rotation).has_value();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<BeltDragResolved> resolveBeltDragPath(const std::vector<BeltPathTile>& path,
|
||||||
|
const FactoryState& state,
|
||||||
|
const GameConfig& config,
|
||||||
|
int buildingBlocksStock)
|
||||||
|
{
|
||||||
|
std::vector<BeltDragResolved> resolved;
|
||||||
|
resolved.reserve(path.size());
|
||||||
|
|
||||||
|
const BuildingDef* def = config.buildings.findBuildingDef(BuildingType::Belt);
|
||||||
|
const int beltCost = (def != nullptr) ? def->cost : 0;
|
||||||
|
int spent = 0;
|
||||||
|
|
||||||
|
for (const BeltPathTile& entry : path)
|
||||||
|
{
|
||||||
|
BeltDragResolved item;
|
||||||
|
const std::optional<BuildingId> rotateTarget =
|
||||||
|
findRotateInPlaceTarget(state, config, BuildingType::Belt,
|
||||||
|
entry.tile, entry.rotation);
|
||||||
|
if (rotateTarget.has_value())
|
||||||
|
{
|
||||||
|
// A tile holding only a belt (or belt site) is re-oriented, no cost.
|
||||||
|
item.action = BeltTileAction::RotateInPlace;
|
||||||
|
item.affordable = true;
|
||||||
|
item.rotateId = rotateTarget;
|
||||||
|
}
|
||||||
|
else if (canPlaceBuilding(state, config, BuildingType::Belt,
|
||||||
|
entry.tile, entry.rotation))
|
||||||
|
{
|
||||||
|
// Empty, valid cell: a new belt, subject to cumulative affordability.
|
||||||
|
item.action = BeltTileAction::PlaceNew;
|
||||||
|
item.affordable = (spent + beltCost <= buildingBlocksStock);
|
||||||
|
item.rotateId = std::nullopt;
|
||||||
|
if (item.affordable) { spent += beltCost; }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Occupied by a non-belt building/site, or otherwise invalid terrain.
|
||||||
|
item.action = BeltTileAction::Invalid;
|
||||||
|
item.affordable = false;
|
||||||
|
item.rotateId = std::nullopt;
|
||||||
|
}
|
||||||
|
resolved.push_back(item);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
|
|
||||||
|
#include "BeltDragPath.h"
|
||||||
#include "BuildingId.h"
|
#include "BuildingId.h"
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "FactoryState.h"
|
#include "FactoryState.h"
|
||||||
@@ -29,10 +30,102 @@ bool bodyCellsWithinWorldBounds(const FactoryState& state, const GameConfig& con
|
|||||||
bool isPlacementValid(const FactoryState& state, const GameConfig& config,
|
bool isPlacementValid(const FactoryState& state, const GameConfig& config,
|
||||||
BuildingType type, QPoint anchor, Rotation rotation);
|
BuildingType type, QPoint anchor, Rotation rotation);
|
||||||
|
|
||||||
|
// An existing building or site whose footprint a ghost exactly covers.
|
||||||
|
struct CoincidingBuilding
|
||||||
|
{
|
||||||
|
BuildingId id;
|
||||||
|
Rotation rotation;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The building or site whose footprint a ghost of the given type/anchor/rotation
|
||||||
|
// exactly covers: same type, same body cells, a single owner. Operational buildings
|
||||||
|
// and construction sites alike. Rotation is not part of the test -- the target's own
|
||||||
|
// facing is reported so each caller can apply its own rule to it.
|
||||||
|
std::optional<CoincidingBuilding> findCoincidingSameTypeBuilding(const FactoryState& state,
|
||||||
|
const GameConfig& config,
|
||||||
|
BuildingType type,
|
||||||
|
QPoint anchor,
|
||||||
|
Rotation rot);
|
||||||
|
|
||||||
// The building or site that a ghost of the given type/anchor/rotation would
|
// The building or site that a ghost of the given type/anchor/rotation would
|
||||||
// rotate in place rather than replace: same type, same body cells, one owner
|
// rotate in place rather than replace: same type, same body cells, one owner
|
||||||
// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify.
|
// (REQ-BLD-ROTATE-IN-PLACE). Tunnels never qualify. Builder mode only; blueprint
|
||||||
|
// placement mode never rotates anything -- see resolveBlueprintGhost.
|
||||||
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
|
std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
|
||||||
const GameConfig& config,
|
const GameConfig& config,
|
||||||
BuildingType type, QPoint anchor,
|
BuildingType type, QPoint anchor,
|
||||||
Rotation rot);
|
Rotation rot);
|
||||||
|
|
||||||
|
// True if placing here would actually do something: the terrain and bounds rules of
|
||||||
|
// isPlacementValid hold, and the body cells are either all free or occupied only by
|
||||||
|
// a building this placement would rotate in place. This is the question the ghost
|
||||||
|
// asks to colour itself and the click path asks before enqueuing a command
|
||||||
|
// (REQ-BLD-GHOST, REQ-BLD-PLACE-VALID, REQ-BLD-ROTATE-IN-PLACE).
|
||||||
|
bool canPlaceBuilding(const FactoryState& state, const GameConfig& config,
|
||||||
|
BuildingType type, QPoint anchor, Rotation rotation);
|
||||||
|
|
||||||
|
// What one ghost of a blueprint would do at its resolved tile.
|
||||||
|
enum class BlueprintGhostAction
|
||||||
|
{
|
||||||
|
PlaceNew, // free, valid cells: a new construction site, charged for
|
||||||
|
CompatibleOverlap, // the same building is already there: left untouched, free
|
||||||
|
// (REQ-UI-BLUEPRINT-OVERLAP)
|
||||||
|
Transfer, // hand this blueprint's settings to the building already there,
|
||||||
|
// free (REQ-UI-BLUEPRINT-TRANSFER)
|
||||||
|
Invalid // terrain, bounds, or an overlap that is neither of the above
|
||||||
|
};
|
||||||
|
|
||||||
|
struct BlueprintGhostResolved
|
||||||
|
{
|
||||||
|
BlueprintGhostAction action;
|
||||||
|
std::optional<BuildingId> targetId; // set for CompatibleOverlap and Transfer
|
||||||
|
// Where the ghost belongs on screen. The queried anchor and rotation, except at a
|
||||||
|
// hovered transfer target, where the ghost snaps onto the target so it shows what the
|
||||||
|
// click will act on (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
QPoint ghostAnchor;
|
||||||
|
Rotation ghostRotation;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Classifies one ghost of a blueprint against the current factory state
|
||||||
|
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
//
|
||||||
|
// `hoverTile` is set only for a blueprint holding exactly one building, and is then the
|
||||||
|
// tile under the cursor. That gesture is a copying tool rather than a layout, so it finds
|
||||||
|
// its transfer target by hit-testing the cursor instead of by footprint coincidence --
|
||||||
|
// without which a Shipyard could never be targeted at a different facing, its 4x2
|
||||||
|
// footprint covering entirely different tiles once rotated. Pass nullopt for a
|
||||||
|
// constellation, whose ghosts are judged purely by where the blueprint puts them. The
|
||||||
|
// size is read from the blueprint as stored, before locked types are dropped, so the
|
||||||
|
// gesture does not change behavior as the player unlocks things.
|
||||||
|
//
|
||||||
|
// Shared by the ghost coloring and the click path so a preview cannot disagree with what
|
||||||
|
// the click then does -- the same reason resolveBeltDragPath is shared.
|
||||||
|
BlueprintGhostResolved resolveBlueprintGhost(const FactoryState& state,
|
||||||
|
const GameConfig& config,
|
||||||
|
BuildingType type, QPoint anchor,
|
||||||
|
Rotation rotation,
|
||||||
|
std::optional<QPoint> hoverTile);
|
||||||
|
|
||||||
|
// What a belt drag would do to one tile of its path (REQ-BLD-BELT-DRAG).
|
||||||
|
enum class BeltTileAction
|
||||||
|
{
|
||||||
|
PlaceNew, // empty, valid cell: a new belt, subject to affordability
|
||||||
|
RotateInPlace, // already a belt (or belt site): re-oriented, free
|
||||||
|
Invalid // occupied by something else, or invalid terrain
|
||||||
|
};
|
||||||
|
|
||||||
|
struct BeltDragResolved
|
||||||
|
{
|
||||||
|
BeltTileAction action;
|
||||||
|
bool affordable; // meaningful only for PlaceNew
|
||||||
|
std::optional<BuildingId> rotateId; // set only for RotateInPlace
|
||||||
|
};
|
||||||
|
|
||||||
|
// Classifies every tile of a belt drag path against the current factory state,
|
||||||
|
// spending `buildingBlocksStock` cumulatively across the PlaceNew tiles so a path
|
||||||
|
// longer than the player can afford is only partly buildable (REQ-BLD-BELT-DRAG).
|
||||||
|
// Shared so the previewed ghosts and the placement on release cannot disagree.
|
||||||
|
std::vector<BeltDragResolved> resolveBeltDragPath(const std::vector<BeltPathTile>& path,
|
||||||
|
const FactoryState& state,
|
||||||
|
const GameConfig& config,
|
||||||
|
int buildingBlocksStock);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,23 @@ struct PlacedModule
|
|||||||
Rotation rotation;
|
Rotation rotation;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
inline bool operator==(const PlacedModule& a, const PlacedModule& b)
|
||||||
|
{
|
||||||
|
return a.moduleId == b.moduleId && a.position == b.position && a.rotation == b.rotation;
|
||||||
|
}
|
||||||
|
|
||||||
// The complete module configuration for a shipyard's current ship (REQ-MOD-CONFIG).
|
// The complete module configuration for a shipyard's current ship (REQ-MOD-CONFIG).
|
||||||
struct ShipLayoutConfig
|
struct ShipLayoutConfig
|
||||||
{
|
{
|
||||||
std::vector<PlacedModule> placedModules;
|
std::vector<PlacedModule> placedModules;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Deliberately order-sensitive: two layouts holding the same modules in a different
|
||||||
|
// vector order compare unequal. This only decides whether applying a layout is a no-op
|
||||||
|
// (REQ-MAT-INPUT-BUFFER), so the conservative answer is the safe one -- reporting a
|
||||||
|
// change that is not one costs a buffer reset, reporting no change when there is one
|
||||||
|
// would leave the shipyard stale.
|
||||||
|
inline bool operator==(const ShipLayoutConfig& a, const ShipLayoutConfig& b)
|
||||||
|
{
|
||||||
|
return a.placedModules == b.placedModules;
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ static Rotation rotCCW(Rotation r)
|
|||||||
return Rotation::East;
|
return Rotation::East;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building
|
// Mirror of BlueprintLibrary::createBlueprintFromSelection: given per-building
|
||||||
// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division
|
// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division
|
||||||
// bounding-box center and per-building tile offsets.
|
// bounding-box center and per-building tile offsets.
|
||||||
struct BuildingSpec
|
struct BuildingSpec
|
||||||
@@ -149,7 +149,7 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
|
// Mirrors BlueprintLibrary::createBlueprintFromSelection's player-placeable filter:
|
||||||
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
|
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
|
||||||
// are silently excluded before the bounding-box center and offsets are computed.
|
// are silently excluded before the bounding-box center and offsets are computed.
|
||||||
static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs,
|
static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs,
|
||||||
|
|||||||
301
src/test/BuildModeControllerTest.cpp
Normal file
301
src/test/BuildModeControllerTest.cpp
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "BlueprintModeExitedEvent.h"
|
||||||
|
#include "BuildModeController.h"
|
||||||
|
#include "BuilderModeExitedEvent.h"
|
||||||
|
#include "DeconstructModeChangedEvent.h"
|
||||||
|
#include "EventHandler.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Records the mode events, which are the half of this class's contract that the
|
||||||
|
// panels depend on: a mode that ends without announcing it leaves its button
|
||||||
|
// stuck highlighted.
|
||||||
|
class ModeEventSpy : public CombinedEventHandler<BuilderModeExitedEvent,
|
||||||
|
BlueprintModeExitedEvent,
|
||||||
|
DeconstructModeChangedEvent>
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ModeEventSpy() { registerForEvents(); }
|
||||||
|
~ModeEventSpy() { unregisterForEvents(); }
|
||||||
|
|
||||||
|
int builderExits = 0;
|
||||||
|
int blueprintExits = 0;
|
||||||
|
int deconstructChanges = 0;
|
||||||
|
bool lastDeconstructActive = false;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/) override
|
||||||
|
{
|
||||||
|
++builderExits;
|
||||||
|
}
|
||||||
|
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/) override
|
||||||
|
{
|
||||||
|
++blueprintExits;
|
||||||
|
}
|
||||||
|
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override
|
||||||
|
{
|
||||||
|
++deconstructChanges;
|
||||||
|
lastDeconstructActive = event->active;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
static Blueprint makeBlueprint()
|
||||||
|
{
|
||||||
|
Blueprint blueprint;
|
||||||
|
BlueprintBuilding building;
|
||||||
|
building.type = BuildingType::Belt;
|
||||||
|
building.offset = QPoint(0, 0);
|
||||||
|
building.rotation = Rotation::East;
|
||||||
|
blueprint.buildings.push_back(building);
|
||||||
|
return blueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exclusivity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Only one mode is active at a time", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
REQUIRE(controller.getMode() == BuildMode::None);
|
||||||
|
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
REQUIRE(controller.isBuilderMode());
|
||||||
|
REQUIRE_FALSE(controller.isBlueprintMode());
|
||||||
|
REQUIRE_FALSE(controller.isDeconstructMode());
|
||||||
|
|
||||||
|
controller.enterBlueprintMode(makeBlueprint());
|
||||||
|
REQUIRE(controller.isBlueprintMode());
|
||||||
|
REQUIRE_FALSE(controller.isBuilderMode());
|
||||||
|
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
REQUIRE(controller.isDeconstructMode());
|
||||||
|
REQUIRE_FALSE(controller.isBlueprintMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Entering builder mode announces that blueprint mode ended", "[buildmode]")
|
||||||
|
{
|
||||||
|
// Regression: entering builder mode used to drop the blueprint silently, so the
|
||||||
|
// blueprint library kept tracking an active blueprint for a mode that was over.
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBlueprintMode(makeBlueprint());
|
||||||
|
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
|
||||||
|
REQUIRE(spy.blueprintExits == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Every mode announces its exit however it is left", "[buildmode]")
|
||||||
|
{
|
||||||
|
// The point of routing all transitions through one place: which mode the player
|
||||||
|
// switches to must not change what the mode they left announces.
|
||||||
|
SECTION("builder, left for a blueprint")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.enterBlueprintMode(makeBlueprint());
|
||||||
|
REQUIRE(spy.builderExits == 1);
|
||||||
|
}
|
||||||
|
SECTION("builder, left for deconstruct")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
REQUIRE(spy.builderExits == 1);
|
||||||
|
}
|
||||||
|
SECTION("blueprint, left for deconstruct")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBlueprintMode(makeBlueprint());
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
REQUIRE(spy.blueprintExits == 1);
|
||||||
|
}
|
||||||
|
SECTION("deconstruct, left for builder")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
REQUIRE(spy.deconstructChanges == 1);
|
||||||
|
REQUIRE_FALSE(spy.lastDeconstructActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Switching between builder types stays in builder mode", "[buildmode]")
|
||||||
|
{
|
||||||
|
// Picking a different building is not leaving the mode, so nothing is announced.
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.enterBuilderMode(BuildingType::Splitter);
|
||||||
|
|
||||||
|
REQUIRE(controller.getBuilderType() == BuildingType::Splitter);
|
||||||
|
REQUIRE(spy.builderExits == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Exiting a mode that is not active does nothing", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.exitBlueprintMode();
|
||||||
|
|
||||||
|
REQUIRE(controller.isBuilderMode());
|
||||||
|
REQUIRE(spy.blueprintExits == 0);
|
||||||
|
REQUIRE(spy.builderExits == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Deconstruct mode toggles off and announces both edges", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
REQUIRE(controller.isDeconstructMode());
|
||||||
|
REQUIRE(spy.lastDeconstructActive);
|
||||||
|
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
REQUIRE(controller.getMode() == BuildMode::None);
|
||||||
|
REQUIRE_FALSE(spy.lastDeconstructActive);
|
||||||
|
REQUIRE(spy.deconstructChanges == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Leaving the current mode works from any of them", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
controller.exitCurrentMode();
|
||||||
|
REQUIRE(controller.getMode() == BuildMode::None);
|
||||||
|
|
||||||
|
controller.enterBlueprintMode(makeBlueprint());
|
||||||
|
controller.exitCurrentMode();
|
||||||
|
REQUIRE(controller.getMode() == BuildMode::None);
|
||||||
|
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
controller.exitCurrentMode();
|
||||||
|
REQUIRE(controller.getMode() == BuildMode::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State cleared on transition
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Leaving builder mode drops an in-progress belt drag", "[buildmode]")
|
||||||
|
{
|
||||||
|
// A drag surviving the mode change would place belts on the next release.
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
controller.beginBeltDrag(QPoint(3, 4));
|
||||||
|
controller.setBeltDragPath({BeltPathTile{QPoint(3, 4), Rotation::East}});
|
||||||
|
REQUIRE(controller.isDraggingBelt());
|
||||||
|
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
|
||||||
|
REQUIRE_FALSE(controller.isDraggingBelt());
|
||||||
|
REQUIRE(controller.getBeltDragPath().empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Leaving deconstruct mode drops the hovered building", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.toggleDeconstructMode();
|
||||||
|
controller.setDeconstructHoverBuildingId(BuildingId(4));
|
||||||
|
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
|
||||||
|
REQUIRE_FALSE(controller.getDeconstructHoverBuildingId().has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Entering builder mode resets the ghost", "[buildmode]")
|
||||||
|
{
|
||||||
|
// A fresh builder starts facing East and invalid until the first hover, rather
|
||||||
|
// than inheriting the previous building's facing (REQ-BLD-GHOST).
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
controller.rotateGhost(true);
|
||||||
|
controller.setGhostValidity(true);
|
||||||
|
|
||||||
|
controller.enterBuilderMode(BuildingType::Splitter);
|
||||||
|
|
||||||
|
REQUIRE(controller.getGhostRotation() == Rotation::East);
|
||||||
|
REQUIRE_FALSE(controller.isGhostValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Cancelling a belt drag stays in builder mode", "[buildmode]")
|
||||||
|
{
|
||||||
|
// Right-click during a drag abandons the path but keeps the belt selected
|
||||||
|
// (REQ-BLD-BELT-DRAG).
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
controller.beginBeltDrag(QPoint(1, 1));
|
||||||
|
|
||||||
|
ModeEventSpy spy;
|
||||||
|
controller.cancelBeltDrag();
|
||||||
|
|
||||||
|
REQUIRE(controller.isBuilderMode());
|
||||||
|
REQUIRE_FALSE(controller.isDraggingBelt());
|
||||||
|
REQUIRE(spy.builderExits == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ghost and tunnel state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Rotating the ghost cycles through the four facings", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
|
||||||
|
controller.rotateGhost(true);
|
||||||
|
REQUIRE(controller.getGhostRotation() == Rotation::South);
|
||||||
|
controller.rotateGhost(true);
|
||||||
|
REQUIRE(controller.getGhostRotation() == Rotation::West);
|
||||||
|
controller.rotateGhost(false);
|
||||||
|
REQUIRE(controller.getGhostRotation() == Rotation::South);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Tunnel mode is the tunnel entry builder type", "[buildmode]")
|
||||||
|
{
|
||||||
|
// REQ-BLD-TUNNEL-MODE: one builder type covers both tunnel ends.
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
REQUIRE_FALSE(controller.isTunnelMode());
|
||||||
|
|
||||||
|
controller.enterBuilderMode(BuildingType::TunnelEntry);
|
||||||
|
REQUIRE(controller.isTunnelMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The effective builder type follows the resolved tunnel end", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::TunnelEntry);
|
||||||
|
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelEntry);
|
||||||
|
|
||||||
|
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5));
|
||||||
|
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::TunnelExit);
|
||||||
|
REQUIRE(controller.getTunnelPartnerTile() == QPoint(5, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A non-tunnel builder ignores any resolved tunnel end", "[buildmode]")
|
||||||
|
{
|
||||||
|
BuildModeController controller;
|
||||||
|
controller.enterBuilderMode(BuildingType::TunnelEntry);
|
||||||
|
controller.setTunnelGhost(BuildingType::TunnelExit, QPoint(5, 5));
|
||||||
|
|
||||||
|
controller.enterBuilderMode(BuildingType::Belt);
|
||||||
|
|
||||||
|
REQUIRE(controller.getEffectiveBuilderType() == BuildingType::Belt);
|
||||||
|
REQUIRE_FALSE(controller.getTunnelPartnerTile().has_value());
|
||||||
|
}
|
||||||
@@ -16,10 +16,10 @@
|
|||||||
#include "SimulationTestAccess.h"
|
#include "SimulationTestAccess.h"
|
||||||
#include "TestConfig.h"
|
#include "TestConfig.h"
|
||||||
|
|
||||||
// readBuildingConfig underpins the copy-settings gesture (REQ-BLD-COPY-CONFIG):
|
// readBuildingConfig underpins blueprint capture (REQ-UI-BLUEPRINT-STORAGE): it
|
||||||
// it extracts a building's recipe / schematic / layout / splitter filters so they
|
// extracts a building's recipe / schematic / layout / splitter filters so they can be
|
||||||
// can be stamped onto a same-type building. It reads operational buildings and
|
// stored in the blueprint and reapplied on placement. It reads operational buildings
|
||||||
// construction sites alike.
|
// and construction sites alike.
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -60,7 +60,7 @@ const ShipDef* findAvailableSchematic(const GameConfig& cfg)
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]")
|
TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[blueprint]")
|
||||||
{
|
{
|
||||||
const GameConfig cfg = loadTestConfig();
|
const GameConfig cfg = loadTestConfig();
|
||||||
Simulation sim(loadTestConfig(), 7);
|
Simulation sim(loadTestConfig(), 7);
|
||||||
@@ -78,7 +78,7 @@ TEST_CASE("readBuildingConfig returns a miner's selected recipe", "[copyconfig]"
|
|||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
|
TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
|
||||||
"[copyconfig]")
|
"[blueprint]")
|
||||||
{
|
{
|
||||||
const GameConfig cfg = loadTestConfig();
|
const GameConfig cfg = loadTestConfig();
|
||||||
Simulation sim(loadTestConfig(), 7);
|
Simulation sim(loadTestConfig(), 7);
|
||||||
@@ -93,7 +93,7 @@ TEST_CASE("readBuildingConfig leaves recipe unset when nothing is selected",
|
|||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
|
TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
|
||||||
"[copyconfig]")
|
"[blueprint]")
|
||||||
{
|
{
|
||||||
const GameConfig cfg = loadTestConfig();
|
const GameConfig cfg = loadTestConfig();
|
||||||
Simulation sim(loadTestConfig(), 7);
|
Simulation sim(loadTestConfig(), 7);
|
||||||
@@ -103,17 +103,28 @@ TEST_CASE("readBuildingConfig returns a shipyard's schematic and layout",
|
|||||||
|
|
||||||
const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0));
|
const BuildingId id = placeOperational(sim, cfg, BuildingType::Shipyard, QPoint(0, 0));
|
||||||
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, schematic->id);
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), id, schematic->id);
|
||||||
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, ShipLayoutConfig{});
|
|
||||||
|
// A real layout, not an empty one: applying an empty layout to a shipyard that has
|
||||||
|
// none is a no-op (REQ-MAT-INPUT-BUFFER), so it would leave nothing to read back.
|
||||||
|
ShipLayoutConfig layout;
|
||||||
|
PlacedModule placed;
|
||||||
|
placed.moduleId = "armor_plate";
|
||||||
|
placed.position = QPoint(0, 0);
|
||||||
|
placed.rotation = Rotation::East;
|
||||||
|
layout.placedModules.push_back(placed);
|
||||||
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), id, layout);
|
||||||
|
|
||||||
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
|
const std::optional<BuildingConfig> config = readBuildingConfig(sim, id);
|
||||||
REQUIRE(config.has_value());
|
REQUIRE(config.has_value());
|
||||||
CHECK(config->type == BuildingType::Shipyard);
|
CHECK(config->type == BuildingType::Shipyard);
|
||||||
REQUIRE(config->recipeId.has_value());
|
REQUIRE(config->recipeId.has_value());
|
||||||
CHECK(*config->recipeId == schematic->id);
|
CHECK(*config->recipeId == schematic->id);
|
||||||
CHECK(config->shipLayout.has_value());
|
REQUIRE(config->shipLayout.has_value());
|
||||||
|
REQUIRE(config->shipLayout->placedModules.size() == 1);
|
||||||
|
CHECK(config->shipLayout->placedModules[0].moduleId == "armor_plate");
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
|
TEST_CASE("readBuildingConfig reads a queued construction site", "[blueprint]")
|
||||||
{
|
{
|
||||||
const GameConfig cfg = loadTestConfig();
|
const GameConfig cfg = loadTestConfig();
|
||||||
Simulation sim(loadTestConfig(), 7);
|
Simulation sim(loadTestConfig(), 7);
|
||||||
@@ -134,8 +145,105 @@ TEST_CASE("readBuildingConfig reads a queued construction site", "[copyconfig]")
|
|||||||
CHECK(*config->recipeId == "mine_iron_ore");
|
CHECK(*config->recipeId == "mine_iron_ore");
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]")
|
TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[blueprint]")
|
||||||
{
|
{
|
||||||
Simulation sim(loadTestConfig(), 7);
|
Simulation sim(loadTestConfig(), 7);
|
||||||
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
|
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The derived facts a blueprint card shows (REQ-UI-BLUEPRINT-CARD). They live here
|
||||||
|
// rather than in the dialog so they can be tested: src/ui is off the test include path.
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
Blueprint makeBlueprint(const std::vector<BuildingType>& types)
|
||||||
|
{
|
||||||
|
Blueprint blueprint;
|
||||||
|
for (const BuildingType type : types)
|
||||||
|
{
|
||||||
|
BlueprintBuilding building;
|
||||||
|
building.type = type;
|
||||||
|
building.rotation = Rotation::East;
|
||||||
|
blueprint.buildings.push_back(building);
|
||||||
|
}
|
||||||
|
return blueprint;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("computeBlueprintCost sums the constituent placement costs", "[blueprint]")
|
||||||
|
{
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
|
||||||
|
// belt 2 + belt 2 + miner 15.
|
||||||
|
const Blueprint blueprint = makeBlueprint(
|
||||||
|
{BuildingType::Belt, BuildingType::Belt, BuildingType::Miner});
|
||||||
|
CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("computeBlueprintCost ignores types absent from buildings.toml", "[blueprint]")
|
||||||
|
{
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
REQUIRE(cfg.buildings.findBuildingDef(BuildingType::Hq) == nullptr);
|
||||||
|
|
||||||
|
const Blueprint blueprint = makeBlueprint({BuildingType::Belt, BuildingType::Hq});
|
||||||
|
CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("computeBlueprintCost is zero for an empty blueprint", "[blueprint]")
|
||||||
|
{
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
CHECK(computeBlueprintCost(Blueprint{}, cfg.buildings) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("summarizeBlueprintContents orders by descending count", "[blueprint]")
|
||||||
|
{
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
|
||||||
|
const Blueprint blueprint = makeBlueprint(
|
||||||
|
{BuildingType::Belt, BuildingType::Smelter, BuildingType::Miner,
|
||||||
|
BuildingType::Miner, BuildingType::Smelter, BuildingType::Miner});
|
||||||
|
|
||||||
|
const std::vector<BlueprintContentEntry> summary =
|
||||||
|
summarizeBlueprintContents(blueprint, cfg.buildings);
|
||||||
|
REQUIRE(summary.size() == 3);
|
||||||
|
CHECK(summary[0].buildingName == "Miner");
|
||||||
|
CHECK(summary[0].count == 3);
|
||||||
|
CHECK(summary[1].buildingName == "Smelter");
|
||||||
|
CHECK(summary[1].count == 2);
|
||||||
|
CHECK(summary[2].buildingName == "Belt");
|
||||||
|
CHECK(summary[2].count == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("summarizeBlueprintContents breaks ties in buildings.toml order", "[blueprint]")
|
||||||
|
{
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
|
||||||
|
// Miner is declared after Belt in buildings.toml, which is the order the build
|
||||||
|
// button bar lays its buttons out, so Belt wins the tie despite the equal counts
|
||||||
|
// and despite sorting later by enum value.
|
||||||
|
const Blueprint blueprint = makeBlueprint(
|
||||||
|
{BuildingType::Miner, BuildingType::Belt, BuildingType::Miner, BuildingType::Belt});
|
||||||
|
|
||||||
|
const std::vector<BlueprintContentEntry> summary =
|
||||||
|
summarizeBlueprintContents(blueprint, cfg.buildings);
|
||||||
|
REQUIRE(summary.size() == 2);
|
||||||
|
CHECK(summary[0].buildingName == "Belt");
|
||||||
|
CHECK(summary[1].buildingName == "Miner");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("summarizeBlueprintContents spells multi-word ids as display names", "[blueprint]")
|
||||||
|
{
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
|
||||||
|
const Blueprint blueprint = makeBlueprint({BuildingType::TunnelEntry});
|
||||||
|
const std::vector<BlueprintContentEntry> summary =
|
||||||
|
summarizeBlueprintContents(blueprint, cfg.buildings);
|
||||||
|
REQUIRE(summary.size() == 1);
|
||||||
|
CHECK(summary[0].buildingName == "Tunnel Entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("summarizeBlueprintContents is empty for an empty blueprint", "[blueprint]")
|
||||||
|
{
|
||||||
|
const GameConfig cfg = loadTestConfig();
|
||||||
|
CHECK(summarizeBlueprintContents(Blueprint{}, cfg.buildings).empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1039,6 +1039,292 @@ TEST_CASE("BuildingSystem: findRotateInPlaceTarget works for a symmetric multi-t
|
|||||||
REQUIRE(*result == id);
|
REQUIRE(*result == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveBlueprintGhost
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// What a blueprint ghost does where it meets an existing building
|
||||||
|
// (REQ-UI-BLUEPRINT-OVERLAP, REQ-UI-BLUEPRINT-TRANSFER). Blueprint placement never
|
||||||
|
// rotates anything, so a coinciding building must either take the blueprint's settings
|
||||||
|
// or already match it exactly.
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// A single-building blueprint, whose cursor sits on the ghost's own anchor unless a test
|
||||||
|
// says otherwise. That gesture hit-tests the cursor for its transfer target.
|
||||||
|
BlueprintGhostResolved resolveOne(const PlacementFixture& f, BuildingType type,
|
||||||
|
QPoint anchor, Rotation rotation)
|
||||||
|
{
|
||||||
|
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
BlueprintGhostResolved resolveOneHovering(const PlacementFixture& f, BuildingType type,
|
||||||
|
QPoint anchor, Rotation rotation, QPoint cursorTile)
|
||||||
|
{
|
||||||
|
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, cursorTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One ghost of a constellation: no cursor hit-test, judged purely on where it sits.
|
||||||
|
BlueprintGhostResolved resolveInConstellation(const PlacementFixture& f, BuildingType type,
|
||||||
|
QPoint anchor, Rotation rotation)
|
||||||
|
{
|
||||||
|
return resolveBlueprintGhost(f.state, f.cfg, type, anchor, rotation, std::nullopt);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("isConfigurableBuildingType: only types with player-facing settings",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
// The gate on whether a single-building blueprint transfers anything at all.
|
||||||
|
CHECK(isConfigurableBuildingType(BuildingType::Miner));
|
||||||
|
CHECK(isConfigurableBuildingType(BuildingType::Assembler));
|
||||||
|
CHECK(isConfigurableBuildingType(BuildingType::Shipyard));
|
||||||
|
CHECK(isConfigurableBuildingType(BuildingType::Splitter));
|
||||||
|
|
||||||
|
// Smelter and Reprocessing Plant run implicit recipes (REQ-BLD-SMELTER,
|
||||||
|
// REQ-BLD-REPROCESSING) and the rest have no settings whatsoever.
|
||||||
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Smelter));
|
||||||
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::ReprocessingPlant));
|
||||||
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::SalvageBay));
|
||||||
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Belt));
|
||||||
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelEntry));
|
||||||
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::TunnelExit));
|
||||||
|
CHECK_FALSE(isConfigurableBuildingType(BuildingType::Hq));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: free valid cells place a new building", "[blueprint]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
// Anchored on the asteroid (x < 0): a miner is all-asteroid cells. BuildingSystem's
|
||||||
|
// place() skips the terrain rules, but resolveBlueprintGhost applies them.
|
||||||
|
const BlueprintGhostResolved resolved =
|
||||||
|
resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||||
|
CHECK(resolved.action == BlueprintGhostAction::PlaceNew);
|
||||||
|
CHECK_FALSE(resolved.targetId.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: terrain-invalid positions are invalid", "[blueprint]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
// A miner is all-asteroid (A) cells, so it cannot sit out in space (x >= 0).
|
||||||
|
CHECK(resolveOne(f, BuildingType::Miner, QPoint(5, 0), Rotation::East).action
|
||||||
|
== BlueprintGhostAction::Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: overlapping a different building type is invalid",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0);
|
||||||
|
|
||||||
|
CHECK(resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::East).action
|
||||||
|
== BlueprintGhostAction::Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a partial overlap of the same type is invalid",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
// Smelter at (-3,0) covers (-3,0),(-2,0),(-3,1),(-2,1); a ghost at (-2,0) covers only
|
||||||
|
// two of those, so it coincides with nothing and is an ordinary occupied overlap.
|
||||||
|
// Both footprints stay on the asteroid, so terrain is not what fails here.
|
||||||
|
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
|
||||||
|
|
||||||
|
CHECK(resolveOne(f, BuildingType::Smelter, QPoint(-2, 0), Rotation::East).action
|
||||||
|
== BlueprintGhostAction::Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a single configurable building transfers its settings",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
|
||||||
|
|
||||||
|
const BlueprintGhostResolved resolved =
|
||||||
|
resolveOne(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||||
|
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
|
||||||
|
REQUIRE(resolved.targetId.has_value());
|
||||||
|
CHECK(*resolved.targetId == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a transfer ignores the target's rotation", "[blueprint]")
|
||||||
|
{
|
||||||
|
// A transfer never rotates anything, so which way the target faces cannot matter
|
||||||
|
// (REQ-UI-BLUEPRINT-TRANSFER). The ghost snaps to the target's facing rather than
|
||||||
|
// keeping the blueprint's.
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0).value();
|
||||||
|
|
||||||
|
const BlueprintGhostResolved resolved =
|
||||||
|
resolveOne(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North);
|
||||||
|
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
|
||||||
|
REQUIRE(resolved.targetId.has_value());
|
||||||
|
CHECK(*resolved.targetId == id);
|
||||||
|
CHECK(resolved.ghostRotation == Rotation::East);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a single-building blueprint transfers from anywhere on the target",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
// The point of hit-testing the cursor instead of comparing footprints. Coincidence
|
||||||
|
// needs the ghost's anchor to land on the target's own anchor, so with a 2x2 body
|
||||||
|
// three of its four tiles missed and read as an ordinary overlap. Hovering any body
|
||||||
|
// tile now targets it, and the ghost snaps onto the building
|
||||||
|
// (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
// Assembler body covers (-3,0),(-2,0),(-3,1),(-2,1); its anchor is (-3,0).
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();
|
||||||
|
|
||||||
|
const QPoint offAnchorTile(-2, 1);
|
||||||
|
const BlueprintGhostResolved resolved =
|
||||||
|
resolveOneHovering(f, BuildingType::Assembler, offAnchorTile, Rotation::East,
|
||||||
|
offAnchorTile);
|
||||||
|
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
|
||||||
|
CHECK(*resolved.targetId == id);
|
||||||
|
CHECK(resolved.ghostAnchor == QPoint(-3, 0));
|
||||||
|
|
||||||
|
// The same misaligned ghost inside a constellation still just overlaps invalidly:
|
||||||
|
// a layout is placed where the blueprint puts it, and nothing snaps.
|
||||||
|
CHECK(resolveInConstellation(f, BuildingType::Assembler, offAnchorTile, Rotation::East).action
|
||||||
|
== BlueprintGhostAction::Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: hovering a different building type does not transfer",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
f.bs.place(f.state, BuildingType::Smelter, QPoint(-3, 0), Rotation::East, 0);
|
||||||
|
|
||||||
|
// A miner blueprint over a smelter: the cursor hit-test only matches its own type.
|
||||||
|
CHECK(resolveOne(f, BuildingType::Miner, QPoint(-3, 0), Rotation::East).action
|
||||||
|
== BlueprintGhostAction::Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a construction site is a transfer target too",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
// Not ticked to completion, so it is still queued (REQ-BLD-SITE-CONFIG).
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Assembler, QPoint(-3, 0), Rotation::East, 0).value();
|
||||||
|
REQUIRE_FALSE(getAllSites(f.state).empty());
|
||||||
|
|
||||||
|
const BlueprintGhostResolved resolved =
|
||||||
|
resolveOne(f, BuildingType::Assembler, QPoint(-3, 0), Rotation::East);
|
||||||
|
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
|
||||||
|
CHECK(*resolved.targetId == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a single building with no settings overlaps instead",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
// A belt carries nothing to transfer, so the same footprint is a compatible overlap
|
||||||
|
// when the facings match -- and invalid when they do not, since nothing here may
|
||||||
|
// re-orient it (REQ-UI-BLUEPRINT-OVERLAP).
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Belt, QPoint(-1, 0), Rotation::East, 0).value();
|
||||||
|
|
||||||
|
const BlueprintGhostResolved matching =
|
||||||
|
resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
|
||||||
|
REQUIRE(matching.action == BlueprintGhostAction::CompatibleOverlap);
|
||||||
|
CHECK(*matching.targetId == id);
|
||||||
|
|
||||||
|
CHECK(resolveOne(f, BuildingType::Belt, QPoint(-1, 0), Rotation::North).action
|
||||||
|
== BlueprintGhostAction::Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a constellation transfers onto a matching building",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
// Blueprint size does not gate the transfer itself: a configurable building already
|
||||||
|
// standing where the blueprint wants it, facing the same way, takes its settings
|
||||||
|
// whatever else the blueprint holds (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
|
||||||
|
|
||||||
|
const BlueprintGhostResolved resolved =
|
||||||
|
resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||||
|
REQUIRE(resolved.action == BlueprintGhostAction::Transfer);
|
||||||
|
CHECK(*resolved.targetId == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a constellation still requires a matching rotation",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
// Only the single-building gesture is forgiving about facing. A constellation's
|
||||||
|
// ghosts stay where the blueprint puts them, and one that cannot be re-oriented to
|
||||||
|
// match blocks the whole placement (REQ-UI-BLUEPRINT-OVERLAP).
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
f.bs.place(f.state, BuildingType::Splitter, QPoint(-1, 0), Rotation::East, 0);
|
||||||
|
|
||||||
|
CHECK(resolveInConstellation(f, BuildingType::Splitter, QPoint(-1, 0), Rotation::North).action
|
||||||
|
== BlueprintGhostAction::Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: a constellation mixes transfers and plain overlaps",
|
||||||
|
"[blueprint]")
|
||||||
|
{
|
||||||
|
// One drop can reconfigure some of the buildings already there while leaving others
|
||||||
|
// alone: the split is by whether the type has settings at all, not by blueprint size
|
||||||
|
// (REQ-UI-BLUEPRINT-OVERLAP).
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId minerId =
|
||||||
|
f.bs.place(f.state, BuildingType::Miner, QPoint(-2, 0), Rotation::East, 0).value();
|
||||||
|
const BuildingId smelterId =
|
||||||
|
f.bs.place(f.state, BuildingType::Smelter, QPoint(-5, 0), Rotation::East, 0).value();
|
||||||
|
|
||||||
|
const BlueprintGhostResolved miner =
|
||||||
|
resolveInConstellation(f, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||||
|
REQUIRE(miner.action == BlueprintGhostAction::Transfer);
|
||||||
|
CHECK(*miner.targetId == minerId);
|
||||||
|
|
||||||
|
// A smelter runs an implicit recipe (REQ-BLD-SMELTER), so there is nothing to hand
|
||||||
|
// over and it is simply left as it is.
|
||||||
|
const BlueprintGhostResolved smelter =
|
||||||
|
resolveInConstellation(f, BuildingType::Smelter, QPoint(-5, 0), Rotation::East);
|
||||||
|
REQUIRE(smelter.action == BlueprintGhostAction::CompatibleOverlap);
|
||||||
|
CHECK(*smelter.targetId == smelterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("resolveBlueprintGhost: an identical tunnel is a compatible overlap", "[blueprint]")
|
||||||
|
{
|
||||||
|
// findRotateInPlaceTarget refuses tunnels because re-orienting one is unsupported.
|
||||||
|
// Nothing is re-oriented here, so that reason does not apply and the tunnel the
|
||||||
|
// blueprint wants -- already there, same facing -- is simply left alone.
|
||||||
|
PlacementFixture f;
|
||||||
|
|
||||||
|
const BuildingId id =
|
||||||
|
f.bs.place(f.state, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East, 0).value();
|
||||||
|
f.bs.place(f.state, BuildingType::TunnelExit, QPoint(-2, 0), Rotation::East, 0);
|
||||||
|
|
||||||
|
REQUIRE_FALSE(
|
||||||
|
findRotateInPlaceTarget(f.state, f.cfg, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East)
|
||||||
|
.has_value());
|
||||||
|
|
||||||
|
const BlueprintGhostResolved resolved =
|
||||||
|
resolveInConstellation(f, BuildingType::TunnelEntry, QPoint(-1, 0), Rotation::East);
|
||||||
|
REQUIRE(resolved.action == BlueprintGhostAction::CompatibleOverlap);
|
||||||
|
CHECK(*resolved.targetId == id);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// rotateInPlace
|
// rotateInPlace
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ add_files(
|
|||||||
SurfaceMaskTest.cpp
|
SurfaceMaskTest.cpp
|
||||||
BeltDragPathTest.cpp
|
BeltDragPathTest.cpp
|
||||||
TunnelCompletionTest.cpp
|
TunnelCompletionTest.cpp
|
||||||
|
WorldCoordinatesTest.cpp
|
||||||
|
WorldCameraTest.cpp
|
||||||
|
SelectionControllerTest.cpp
|
||||||
|
BuildModeControllerTest.cpp
|
||||||
|
ControlActionTest.cpp
|
||||||
BuildingTest.cpp
|
BuildingTest.cpp
|
||||||
BuildingConfigTest.cpp
|
BuildingConfigTest.cpp
|
||||||
ShipTest.cpp
|
ShipTest.cpp
|
||||||
|
|||||||
328
src/test/ControlActionTest.cpp
Normal file
328
src/test/ControlActionTest.cpp
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "BuildModeController.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "ControlAction.h"
|
||||||
|
|
||||||
|
// REQ-UI-CONTROLS-ACCURACY. The panel and the input handling read one table, and these
|
||||||
|
// tests are what makes that pay: the round-trip case below fails the moment a row is
|
||||||
|
// shown whose binding resolves elsewhere, which is the drift the whole design exists to
|
||||||
|
// prevent. Everything here works on action ids -- the display strings live in the ui
|
||||||
|
// target and are not what can silently go wrong.
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
ControlContext generalContext()
|
||||||
|
{
|
||||||
|
return ControlContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext selectionContext(bool placeable = true)
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.selection = ControlSelection::Buildings;
|
||||||
|
context.selectionCount = 3;
|
||||||
|
context.placeableBuildingSelected = placeable;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext builderContext(BuildingType type)
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.mode = BuildMode::Builder;
|
||||||
|
context.builderType = type;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext blueprintContext(bool transfer = false)
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.mode = BuildMode::Blueprint;
|
||||||
|
context.hoveredGhostIsTransfer = transfer;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlContext deconstructContext()
|
||||||
|
{
|
||||||
|
ControlContext context;
|
||||||
|
context.mode = BuildMode::Deconstruct;
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A spread wide enough that every availability rule and every binding condition is
|
||||||
|
// exercised by the whole-table properties below.
|
||||||
|
std::vector<ControlContext> allContexts()
|
||||||
|
{
|
||||||
|
ControlContext beltDragging = builderContext(BuildingType::Belt);
|
||||||
|
beltDragging.draggingBelt = true;
|
||||||
|
|
||||||
|
ControlContext generalWithBlueprint = generalContext();
|
||||||
|
generalWithBlueprint.temporaryBlueprintExists = true;
|
||||||
|
|
||||||
|
ControlContext fieldSelection = selectionContext(false);
|
||||||
|
fieldSelection.selection = ControlSelection::FieldObjects;
|
||||||
|
|
||||||
|
return {generalContext(),
|
||||||
|
generalWithBlueprint,
|
||||||
|
selectionContext(),
|
||||||
|
fieldSelection,
|
||||||
|
builderContext(BuildingType::Belt),
|
||||||
|
builderContext(BuildingType::Assembler),
|
||||||
|
beltDragging,
|
||||||
|
blueprintContext(false),
|
||||||
|
blueprintContext(true),
|
||||||
|
deconstructContext()};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<ControlAction> shownActions(const ControlContext& context)
|
||||||
|
{
|
||||||
|
std::vector<ControlAction> actions = getContextActions(context);
|
||||||
|
const std::vector<ControlAction> always = getAlwaysAvailableActions(context);
|
||||||
|
actions.insert(actions.end(), always.begin(), always.end());
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool contains(const std::vector<ControlAction>& actions, ControlAction action)
|
||||||
|
{
|
||||||
|
return std::find(actions.begin(), actions.end(), action) != actions.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: every shown row is available", "[controls]")
|
||||||
|
{
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
for (ControlAction action : shownActions(context))
|
||||||
|
{
|
||||||
|
REQUIRE(isControlActionAvailable(action, context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: no row is shown twice", "[controls]")
|
||||||
|
{
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
std::vector<ControlAction> actions = shownActions(context);
|
||||||
|
std::vector<ControlAction> unique = actions;
|
||||||
|
std::sort(unique.begin(), unique.end());
|
||||||
|
unique.erase(std::unique(unique.begin(), unique.end()), unique.end());
|
||||||
|
REQUIRE(unique.size() == actions.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The core anti-drift property: a row's badges are rendered from its bindings, so every
|
||||||
|
// binding a row advertises must actually trigger that row's action in that same context.
|
||||||
|
TEST_CASE("ControlAction: every advertised binding resolves back to its own action",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
for (ControlAction action : shownActions(context))
|
||||||
|
{
|
||||||
|
const std::vector<ControlBinding> bindings =
|
||||||
|
getControlActionBindings(action, context);
|
||||||
|
REQUIRE_FALSE(bindings.empty());
|
||||||
|
|
||||||
|
for (const ControlBinding& binding : bindings)
|
||||||
|
{
|
||||||
|
if (binding.isMouse)
|
||||||
|
{
|
||||||
|
REQUIRE(resolveMouseAction(binding.mouse, context) == action);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
REQUIRE(resolveKeyAction(binding.key, binding.modifiers, context)
|
||||||
|
== action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other direction: an input that resolves to something must resolve to an action
|
||||||
|
// that is actually available there, so no input can trigger a no-op. This is weaker
|
||||||
|
// than "must be a shown row" on purpose -- a context may omit an available binding
|
||||||
|
// (Ctrl+click in the General context), which REQ-UI-CONTROLS-ACCURACY permits; what it
|
||||||
|
// may never do is act on an unavailable one.
|
||||||
|
TEST_CASE("ControlAction: every resolvable input is available", "[controls]")
|
||||||
|
{
|
||||||
|
const std::vector<MouseBinding> mouseBindings = {
|
||||||
|
MouseBinding::LeftClick, MouseBinding::LeftDrag, MouseBinding::CtrlLeftClick,
|
||||||
|
MouseBinding::CtrlLeftDrag, MouseBinding::RightClick};
|
||||||
|
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
for (MouseBinding binding : mouseBindings)
|
||||||
|
{
|
||||||
|
const ControlAction action = resolveMouseAction(binding, context);
|
||||||
|
if (action != ControlAction::None)
|
||||||
|
{
|
||||||
|
REQUIRE(isControlActionAvailable(action, context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<int> keys = {Qt::Key_A, Qt::Key_D, Qt::Key_W, Qt::Key_S,
|
||||||
|
Qt::Key_Space, Qt::Key_C, Qt::Key_V, Qt::Key_R,
|
||||||
|
Qt::Key_Q, Qt::Key_Escape};
|
||||||
|
for (int key : keys)
|
||||||
|
{
|
||||||
|
for (Qt::KeyboardModifiers modifiers :
|
||||||
|
{Qt::KeyboardModifiers(Qt::NoModifier),
|
||||||
|
Qt::KeyboardModifiers(Qt::ShiftModifier),
|
||||||
|
Qt::KeyboardModifiers(Qt::ControlModifier)})
|
||||||
|
{
|
||||||
|
const ControlAction action = resolveKeyAction(key, modifiers, context);
|
||||||
|
if (action != ControlAction::None)
|
||||||
|
{
|
||||||
|
REQUIRE(isControlActionAvailable(action, context));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: contexts are named by mode and selection", "[controls]")
|
||||||
|
{
|
||||||
|
REQUIRE(getControlContextKind(generalContext()) == ControlContextKind::General);
|
||||||
|
REQUIRE(getControlContextKind(selectionContext()) == ControlContextKind::Selection);
|
||||||
|
REQUIRE(getControlContextKind(builderContext(BuildingType::Belt))
|
||||||
|
== ControlContextKind::Build);
|
||||||
|
REQUIRE(getControlContextKind(blueprintContext()) == ControlContextKind::Blueprint);
|
||||||
|
REQUIRE(getControlContextKind(deconstructContext())
|
||||||
|
== ControlContextKind::Deconstruct);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: Q enters deconstruct mode, or leaves the active one",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, generalContext())
|
||||||
|
== ControlAction::EnterDeconstruct);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, selectionContext())
|
||||||
|
== ControlAction::EnterDeconstruct);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, builderContext(BuildingType::Belt))
|
||||||
|
== ControlAction::ExitMode);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, blueprintContext())
|
||||||
|
== ControlAction::ExitMode);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, deconstructContext())
|
||||||
|
== ControlAction::ExitMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: Ctrl distinguishes the chords, other modifiers do not",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
const ControlContext context = selectionContext();
|
||||||
|
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, context)
|
||||||
|
== ControlAction::CopyTemporary);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::ControlModifier, context)
|
||||||
|
== ControlAction::CreateBlueprint);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
|
||||||
|
== ControlAction::OpenBlueprints);
|
||||||
|
|
||||||
|
// Shift+A must still pan, as it always has -- matching modifiers exactly instead of
|
||||||
|
// testing Ctrl alone would silently swallow the press.
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_A, Qt::ShiftModifier, context) == ControlAction::Move);
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_R, Qt::ShiftModifier, blueprintContext())
|
||||||
|
== ControlAction::Rotate);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: a transfer target turns the click into Apply settings",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
const ControlContext plain = blueprintContext(false);
|
||||||
|
const ControlContext transfer = blueprintContext(true);
|
||||||
|
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, plain) == ControlAction::Place);
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftClick, transfer)
|
||||||
|
== ControlAction::ApplySettings);
|
||||||
|
|
||||||
|
REQUIRE(contains(getContextActions(plain), ControlAction::Place));
|
||||||
|
REQUIRE_FALSE(contains(getContextActions(plain), ControlAction::ApplySettings));
|
||||||
|
REQUIRE(contains(getContextActions(transfer), ControlAction::ApplySettings));
|
||||||
|
REQUIRE_FALSE(contains(getContextActions(transfer), ControlAction::Place));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: a belt drag takes the right mouse button from ExitMode",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
ControlContext dragging = builderContext(BuildingType::Belt);
|
||||||
|
dragging.draggingBelt = true;
|
||||||
|
const ControlContext idle = builderContext(BuildingType::Belt);
|
||||||
|
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::RightClick, idle) == ControlAction::ExitMode);
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::RightClick, dragging)
|
||||||
|
== ControlAction::CancelBeltLine);
|
||||||
|
|
||||||
|
// Q still leaves the mode outright while the drag runs, so ExitMode stays shown --
|
||||||
|
// with its right-click badge dropped, since the button now means something else.
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_Q, Qt::NoModifier, dragging) == ControlAction::ExitMode);
|
||||||
|
|
||||||
|
const std::vector<ControlBinding> idleBindings =
|
||||||
|
getControlActionBindings(ControlAction::ExitMode, idle);
|
||||||
|
const std::vector<ControlBinding> dragBindings =
|
||||||
|
getControlActionBindings(ControlAction::ExitMode, dragging);
|
||||||
|
REQUIRE(idleBindings.size() == 2);
|
||||||
|
REQUIRE(dragBindings.size() == 1);
|
||||||
|
REQUIRE_FALSE(dragBindings.front().isMouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: dragging a line is offered for belts only", "[controls]")
|
||||||
|
{
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, builderContext(BuildingType::Belt))
|
||||||
|
== ControlAction::PlaceBeltLine);
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag,
|
||||||
|
builderContext(BuildingType::Assembler))
|
||||||
|
== ControlAction::None);
|
||||||
|
REQUIRE(resolveMouseAction(MouseBinding::LeftDrag, blueprintContext())
|
||||||
|
== ControlAction::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: blueprint keys are offered only where they do something",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
// V does nothing until C has captured something (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
ControlContext withBlueprint = generalContext();
|
||||||
|
withBlueprint.temporaryBlueprintExists = true;
|
||||||
|
REQUIRE_FALSE(contains(shownActions(generalContext()), ControlAction::PasteTemporary));
|
||||||
|
REQUIRE(contains(shownActions(withBlueprint), ControlAction::PasteTemporary));
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::NoModifier, generalContext())
|
||||||
|
== ControlAction::None);
|
||||||
|
|
||||||
|
// C and Ctrl+C need a selection holding something placeable (REQ-UI-HOTKEYS).
|
||||||
|
ControlContext fieldSelection = selectionContext(false);
|
||||||
|
fieldSelection.selection = ControlSelection::FieldObjects;
|
||||||
|
REQUIRE(contains(getContextActions(selectionContext()), ControlAction::CopyTemporary));
|
||||||
|
REQUIRE_FALSE(contains(getContextActions(fieldSelection), ControlAction::CopyTemporary));
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_C, Qt::NoModifier, fieldSelection)
|
||||||
|
== ControlAction::None);
|
||||||
|
|
||||||
|
// Ctrl+V opens the dialog whatever is going on (REQ-UI-HOTKEYS).
|
||||||
|
for (const ControlContext& context : allContexts())
|
||||||
|
{
|
||||||
|
REQUIRE(resolveKeyAction(Qt::Key_V, Qt::ControlModifier, context)
|
||||||
|
== ControlAction::OpenBlueprints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ControlAction: selection rows appear only once something is selected",
|
||||||
|
"[controls]")
|
||||||
|
{
|
||||||
|
const std::vector<ControlAction> general = getContextActions(generalContext());
|
||||||
|
const std::vector<ControlAction> selection = getContextActions(selectionContext());
|
||||||
|
|
||||||
|
REQUIRE(contains(general, ControlAction::Select));
|
||||||
|
REQUIRE(contains(general, ControlAction::EnterDeconstruct));
|
||||||
|
REQUIRE_FALSE(contains(general, ControlAction::AddToSelection));
|
||||||
|
REQUIRE_FALSE(contains(general, ControlAction::CreateBlueprint));
|
||||||
|
|
||||||
|
REQUIRE(contains(selection, ControlAction::AddToSelection));
|
||||||
|
REQUIRE(contains(selection, ControlAction::AddAreaToSelection));
|
||||||
|
REQUIRE(contains(selection, ControlAction::CreateBlueprint));
|
||||||
|
REQUIRE(contains(selection, ControlAction::EnterDeconstruct));
|
||||||
|
}
|
||||||
282
src/test/SelectionControllerTest.cpp
Normal file
282
src/test/SelectionControllerTest.cpp
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "DebrisSelectionChangedEvent.h"
|
||||||
|
#include "EntitySelectionChangedEvent.h"
|
||||||
|
#include "EventHandler.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
#include "SelectionChangedEvent.h"
|
||||||
|
#include "SelectionController.h"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Counts the change events the controller publishes, so the tests can assert not
|
||||||
|
// just the resulting selection but that the widgets were told about it — and, for
|
||||||
|
// the categories a change did not touch, that they were not told needlessly.
|
||||||
|
class SelectionEventSpy : public CombinedEventHandler<SelectionChangedEvent,
|
||||||
|
EntitySelectionChangedEvent,
|
||||||
|
DebrisSelectionChangedEvent>
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SelectionEventSpy() { registerForEvents(); }
|
||||||
|
~SelectionEventSpy() { unregisterForEvents(); }
|
||||||
|
|
||||||
|
int buildingEvents = 0;
|
||||||
|
int actorEvents = 0;
|
||||||
|
int debrisEvents = 0;
|
||||||
|
|
||||||
|
void reset() { buildingEvents = 0; actorEvents = 0; debrisEvents = 0; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void handleEvent(std::shared_ptr<const SelectionChangedEvent> /*event*/) override
|
||||||
|
{
|
||||||
|
++buildingEvents;
|
||||||
|
}
|
||||||
|
void handleEvent(std::shared_ptr<const EntitySelectionChangedEvent> /*event*/) override
|
||||||
|
{
|
||||||
|
++actorEvents;
|
||||||
|
}
|
||||||
|
void handleEvent(std::shared_ptr<const DebrisSelectionChangedEvent> /*event*/) override
|
||||||
|
{
|
||||||
|
++debrisEvents;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
static entt::entity makeEntity(int index)
|
||||||
|
{
|
||||||
|
return static_cast<entt::entity>(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Category precedence (REQ-UI-SELECTION-CATEGORIES)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Selecting a building clears actors and debris", "[selection]")
|
||||||
|
{
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
|
||||||
|
SelectionMode::Replace);
|
||||||
|
|
||||||
|
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedBuildings() == std::vector<BuildingId>{BuildingId(7)});
|
||||||
|
REQUIRE(controller.getSelectedActors().empty());
|
||||||
|
REQUIRE(controller.getSelectedDebris().empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Selecting a field object clears buildings", "[selection]")
|
||||||
|
{
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedBuildings().empty());
|
||||||
|
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Actors and debris coexist", "[selection]")
|
||||||
|
{
|
||||||
|
// The one pair of categories that does not evict each other
|
||||||
|
// (REQ-UI-MULTI-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
|
||||||
|
SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
|
||||||
|
REQUIRE(controller.getSelectedDebris() == std::vector<entt::entity>{makeEntity(2)});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A plain click on an actor drops any selected debris", "[selection]")
|
||||||
|
{
|
||||||
|
// A point click passes only the category it hit, so Replace with an empty
|
||||||
|
// debris list is what "this actor and nothing else" means
|
||||||
|
// (REQ-UI-ENTITY-CLICK-SELECT).
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectFieldObjects({}, {makeEntity(2)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
|
||||||
|
REQUIRE(controller.getSelectedDebris().empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Replace / Toggle / Add
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Replace makes the hit the whole selection", "[selection]")
|
||||||
|
{
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectBuildings({BuildingId(1), BuildingId(2)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
controller.selectBuildings({BuildingId(3)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedBuildings() == std::vector<BuildingId>{BuildingId(3)});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Toggle adds a building that was not selected", "[selection]")
|
||||||
|
{
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectBuildings({BuildingId(1)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
controller.selectBuildings({BuildingId(2)}, SelectionMode::Toggle);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedBuildings()
|
||||||
|
== std::vector<BuildingId>{BuildingId(1), BuildingId(2)});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Toggle removes a building that was already selected", "[selection]")
|
||||||
|
{
|
||||||
|
// Ctrl+clicking a selected building deselects it, leaving the rest alone.
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectBuildings({BuildingId(1), BuildingId(2), BuildingId(3)},
|
||||||
|
SelectionMode::Replace);
|
||||||
|
|
||||||
|
controller.selectBuildings({BuildingId(2)}, SelectionMode::Toggle);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedBuildings()
|
||||||
|
== std::vector<BuildingId>{BuildingId(1), BuildingId(3)});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Add never deselects", "[selection]")
|
||||||
|
{
|
||||||
|
// This is where a Ctrl box drag differs from a Ctrl click: dragging over
|
||||||
|
// already-selected buildings must not toggle them off (REQ-UI-MULTI-SELECT).
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectBuildings({BuildingId(1), BuildingId(2)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
controller.selectBuildings({BuildingId(2), BuildingId(3)}, SelectionMode::Add);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedBuildings()
|
||||||
|
== std::vector<BuildingId>{BuildingId(1), BuildingId(2), BuildingId(3)});
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("An additive field selection leaves the untouched category alone",
|
||||||
|
"[selection]")
|
||||||
|
{
|
||||||
|
// Ctrl+clicking an actor passes an empty debris list, which must mean "do not
|
||||||
|
// touch debris" rather than "clear debris" (REQ-UI-DEBRIS-MULTI-SELECT).
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectFieldObjects({}, {makeEntity(5)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
controller.selectFieldObjects({makeEntity(1)}, {}, SelectionMode::Toggle);
|
||||||
|
|
||||||
|
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
|
||||||
|
REQUIRE(controller.getSelectedDebris() == std::vector<entt::entity>{makeEntity(5)});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Membership queries used by the renderer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Membership queries answer per category", "[selection]")
|
||||||
|
{
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
|
||||||
|
SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(controller.isActorSelected(makeEntity(1)));
|
||||||
|
REQUIRE_FALSE(controller.isActorSelected(makeEntity(2)));
|
||||||
|
REQUIRE(controller.isDebrisSelected(makeEntity(2)));
|
||||||
|
REQUIRE_FALSE(controller.isDebrisSelected(makeEntity(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Published events
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Selecting a building announces the categories it cleared", "[selection]")
|
||||||
|
{
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
|
||||||
|
SelectionMode::Replace);
|
||||||
|
|
||||||
|
SelectionEventSpy spy;
|
||||||
|
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(spy.buildingEvents == 1);
|
||||||
|
REQUIRE(spy.actorEvents == 1);
|
||||||
|
REQUIRE(spy.debrisEvents == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Clearing an already-empty category announces nothing", "[selection]")
|
||||||
|
{
|
||||||
|
// The panels re-read on every event, so a redundant one is only noise — but the
|
||||||
|
// point-click and box paths used to disagree about this, so it is pinned.
|
||||||
|
SelectionController controller;
|
||||||
|
|
||||||
|
SelectionEventSpy spy;
|
||||||
|
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(spy.buildingEvents == 1);
|
||||||
|
REQUIRE(spy.actorEvents == 0);
|
||||||
|
REQUIRE(spy.debrisEvents == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Re-selecting the same building still announces it", "[selection]")
|
||||||
|
{
|
||||||
|
// Clicking an already-selected building refreshes its panel, so the event
|
||||||
|
// fires even though the selection did not change.
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
SelectionEventSpy spy;
|
||||||
|
controller.selectBuildings({BuildingId(7)}, SelectionMode::Replace);
|
||||||
|
|
||||||
|
REQUIRE(spy.buildingEvents == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Clearing an empty selection announces nothing at all", "[selection]")
|
||||||
|
{
|
||||||
|
SelectionController controller;
|
||||||
|
|
||||||
|
SelectionEventSpy spy;
|
||||||
|
controller.clearAll();
|
||||||
|
|
||||||
|
REQUIRE(spy.buildingEvents == 0);
|
||||||
|
REQUIRE(spy.actorEvents == 0);
|
||||||
|
REQUIRE(spy.debrisEvents == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Clearing a populated selection announces every populated category",
|
||||||
|
"[selection]")
|
||||||
|
{
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectFieldObjects({makeEntity(1)}, {makeEntity(2)},
|
||||||
|
SelectionMode::Replace);
|
||||||
|
|
||||||
|
SelectionEventSpy spy;
|
||||||
|
controller.clearAll();
|
||||||
|
|
||||||
|
REQUIRE(spy.buildingEvents == 0); // buildings were already empty
|
||||||
|
REQUIRE(spy.actorEvents == 1);
|
||||||
|
REQUIRE(spy.debrisEvents == 1);
|
||||||
|
REQUIRE(controller.getSelectedActors().empty());
|
||||||
|
REQUIRE(controller.getSelectedDebris().empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pruning despawned entities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Pruning announces only when something actually went", "[selection]")
|
||||||
|
{
|
||||||
|
// Runs every frame, so re-announcing an unchanged selection would spam the
|
||||||
|
// panels 60 times a second.
|
||||||
|
SelectionController controller;
|
||||||
|
controller.selectFieldObjects({makeEntity(1), makeEntity(2)}, {},
|
||||||
|
SelectionMode::Replace);
|
||||||
|
|
||||||
|
SelectionEventSpy spy;
|
||||||
|
controller.setSelectedActors({makeEntity(1), makeEntity(2)});
|
||||||
|
REQUIRE(spy.actorEvents == 0);
|
||||||
|
|
||||||
|
controller.setSelectedActors({makeEntity(1)});
|
||||||
|
REQUIRE(spy.actorEvents == 1);
|
||||||
|
REQUIRE(controller.getSelectedActors() == std::vector<entt::entity>{makeEntity(1)});
|
||||||
|
}
|
||||||
@@ -262,6 +262,123 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
|||||||
CHECK_FALSE(b2->production.has_value());
|
CHECK_FALSE(b2->production.has_value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Applying a configuration a building already has is a no-op (REQ-MAT-INPUT-BUFFER).
|
||||||
|
// setRecipe has always worked this way; setShipLayout did not, and wiped the shipyard
|
||||||
|
// on every re-apply. That matters now that a blueprint configuration transfer
|
||||||
|
// (REQ-UI-BLUEPRINT-TRANSFER) can be clicked repeatedly onto matching shipyards.
|
||||||
|
|
||||||
|
TEST_CASE("Shipyard: re-applying the same layout keeps production and buffers",
|
||||||
|
"[modules][shipyard]")
|
||||||
|
{
|
||||||
|
Simulation sim(loadTestConfig(), 42);
|
||||||
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||||
|
REQUIRE(def != nullptr);
|
||||||
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
||||||
|
REQUIRE(yardDef != nullptr);
|
||||||
|
|
||||||
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||||
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
|
||||||
|
|
||||||
|
ShipLayoutConfig layout;
|
||||||
|
PlacedModule pm;
|
||||||
|
pm.moduleId = "armor_plate";
|
||||||
|
pm.position = QPoint(0, 0);
|
||||||
|
pm.rotation = Rotation::East;
|
||||||
|
layout.placedModules.push_back(pm);
|
||||||
|
|
||||||
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
|
||||||
|
fillMaterials(sim, yardId, *def, layout);
|
||||||
|
sim.tick();
|
||||||
|
|
||||||
|
const Building* before = findBuilding(sim.getFactoryState(), yardId);
|
||||||
|
REQUIRE(before != nullptr);
|
||||||
|
REQUIRE(before->production.has_value());
|
||||||
|
const Tick completesAt = before->production->completesAt;
|
||||||
|
|
||||||
|
// A separately built but equal layout: equality is by value, not by identity.
|
||||||
|
ShipLayoutConfig sameLayout;
|
||||||
|
sameLayout.placedModules.push_back(pm);
|
||||||
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, sameLayout);
|
||||||
|
|
||||||
|
const Building* after = findBuilding(sim.getFactoryState(), yardId);
|
||||||
|
REQUIRE(after != nullptr);
|
||||||
|
REQUIRE(after->production.has_value());
|
||||||
|
CHECK(after->production->completesAt == completesAt);
|
||||||
|
CHECK(after->inputBuffer.caps.at(ItemType{"iron_ingot"}) == 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Shipyard: an empty layout on an unconfigured shipyard changes nothing",
|
||||||
|
"[modules][shipyard]")
|
||||||
|
{
|
||||||
|
// An unset layout and an empty one are the same state everywhere that matters, so
|
||||||
|
// clearing an already-unconfigured shipyard must not cancel its cycle. This is the
|
||||||
|
// case a transfer from a layout-less source shipyard produces.
|
||||||
|
Simulation sim(loadTestConfig(), 42);
|
||||||
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||||
|
REQUIRE(def != nullptr);
|
||||||
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
||||||
|
REQUIRE(yardDef != nullptr);
|
||||||
|
|
||||||
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||||
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
|
||||||
|
|
||||||
|
ShipLayoutConfig emptyLayout;
|
||||||
|
fillMaterials(sim, yardId, *def, emptyLayout);
|
||||||
|
sim.tick();
|
||||||
|
|
||||||
|
const Building* before = findBuilding(sim.getFactoryState(), yardId);
|
||||||
|
REQUIRE(before != nullptr);
|
||||||
|
REQUIRE(before->production.has_value());
|
||||||
|
REQUIRE_FALSE(before->shipLayout.has_value());
|
||||||
|
|
||||||
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, emptyLayout);
|
||||||
|
|
||||||
|
const Building* after = findBuilding(sim.getFactoryState(), yardId);
|
||||||
|
REQUIRE(after != nullptr);
|
||||||
|
CHECK(after->production.has_value());
|
||||||
|
CHECK_FALSE(after->shipLayout.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Shipyard: the same modules in a different order count as a change",
|
||||||
|
"[modules][shipyard]")
|
||||||
|
{
|
||||||
|
// Layout equality is deliberately order-sensitive (see ShipLayout.h): the safe
|
||||||
|
// answer when in doubt is "changed", which costs a buffer reset rather than
|
||||||
|
// leaving a shipyard building the wrong ship.
|
||||||
|
Simulation sim(loadTestConfig(), 42);
|
||||||
|
const ShipDef* def = findSchematic(sim.getConfig(), "interceptor");
|
||||||
|
REQUIRE(def != nullptr);
|
||||||
|
const BuildingDef* yardDef = findShipyardDef(sim.getConfig());
|
||||||
|
REQUIRE(yardDef != nullptr);
|
||||||
|
|
||||||
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||||
|
SimulationTestAccess::buildings(sim).setRecipe(SimulationTestAccess::state(sim), yardId, "interceptor");
|
||||||
|
|
||||||
|
PlacedModule armor;
|
||||||
|
armor.moduleId = "armor_plate";
|
||||||
|
armor.position = QPoint(0, 0);
|
||||||
|
armor.rotation = Rotation::East;
|
||||||
|
PlacedModule sensor;
|
||||||
|
sensor.moduleId = "sensor_booster";
|
||||||
|
sensor.position = QPoint(1, 0);
|
||||||
|
sensor.rotation = Rotation::East;
|
||||||
|
|
||||||
|
ShipLayoutConfig layout;
|
||||||
|
layout.placedModules.push_back(armor);
|
||||||
|
layout.placedModules.push_back(sensor);
|
||||||
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, layout);
|
||||||
|
fillMaterials(sim, yardId, *def, layout);
|
||||||
|
sim.tick();
|
||||||
|
REQUIRE(findBuilding(sim.getFactoryState(), yardId)->production.has_value());
|
||||||
|
|
||||||
|
ShipLayoutConfig reordered;
|
||||||
|
reordered.placedModules.push_back(sensor);
|
||||||
|
reordered.placedModules.push_back(armor);
|
||||||
|
SimulationTestAccess::buildings(sim).setShipLayout(SimulationTestAccess::state(sim), yardId, reordered);
|
||||||
|
|
||||||
|
CHECK_FALSE(findBuilding(sim.getFactoryState(), yardId)->production.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
|
TEST_CASE("Shipyard: builds a bare hull when no layout is configured",
|
||||||
"[modules][shipyard]")
|
"[modules][shipyard]")
|
||||||
{
|
{
|
||||||
|
|||||||
243
src/test/WorldCameraTest.cpp
Normal file
243
src/test/WorldCameraTest.cpp
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include "WorldCamera.h"
|
||||||
|
#include "WorldConfig.h"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Slow 10 tiles/s, fast 50 tiles/s, and a 4-tile ramp band — so each ramp spans
|
||||||
|
// 2 tiles either side of a contest-zone boundary and the arithmetic stays exact.
|
||||||
|
static WorldScroll makeScroll(int rampBandWidth_tiles = 4)
|
||||||
|
{
|
||||||
|
WorldScroll scroll;
|
||||||
|
scroll.panSpeedSlow_tps = 10.0;
|
||||||
|
scroll.panSpeedFast_tps = 50.0;
|
||||||
|
scroll.panRampBandWidth_tiles = rampBandWidth_tiles;
|
||||||
|
return scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The player buffer's right edge — the contest zone's left boundary — sits at 20.
|
||||||
|
static WorldRegions makeRegions()
|
||||||
|
{
|
||||||
|
WorldRegions regions;
|
||||||
|
regions.asteroidWidth_tiles = 10;
|
||||||
|
regions.playerBufferWidth_tiles = 20;
|
||||||
|
regions.contestZoneWidth_tiles = 60;
|
||||||
|
regions.enemyBufferWidth_tiles = 10;
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ScrollBounds makeBounds(float leftTiles = -100.0f, float rightTiles = 100.0f)
|
||||||
|
{
|
||||||
|
return ScrollBounds{leftTiles, rightTiles};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pan speed ramp (REQ-UI-SCROLL-SPEED)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Pan speed is slow over the asteroid and player buffer", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
// Left of the rising ramp band (which starts at 20 - 2 = 18).
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(-50.0f, 80.0f) == Approx(10.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(18.0f, 80.0f) == Approx(10.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Pan speed is fast across the middle of the contest zone", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
// Between the two ramp bands: past 20 + 2 and before 80 - 2.
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(22.0f, 80.0f) == Approx(50.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(78.0f, 80.0f) == Approx(50.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Pan speed ramps linearly across each contest-zone boundary", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
// Rising band spans [18, 22]; the boundary itself is the midpoint.
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 80.0f) == Approx(30.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.0f, 80.0f) == Approx(20.0f));
|
||||||
|
// Falling band spans [78, 82], mirrored.
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.0f, 80.0f) == Approx(30.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(81.0f, 80.0f) == Approx(20.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Pan speed returns to slow past the enemy stations", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(82.0f, 80.0f) == Approx(10.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(200.0f, 80.0f) == Approx(10.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The ramp follows the front line as it is pushed", "[camera]")
|
||||||
|
{
|
||||||
|
// The right boundary is passed in per call, so a push that moves the enemy
|
||||||
|
// stations moves the falling ramp with it (REQ-GW-PUSH-EXPAND).
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
// X = 90 is past the old boundary (slow) but well inside the pushed-back one.
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 80.0f) == Approx(10.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(90.0f, 140.0f) == Approx(50.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Overlapping ramp bands peak below the fast speed", "[camera]")
|
||||||
|
{
|
||||||
|
// A contest zone narrower than the ramp band never reaches full speed: the two
|
||||||
|
// ramps cross before either tops out, leaving a single peak where they meet.
|
||||||
|
const WorldScroll scroll = makeScroll(/*rampBandWidth_tiles*/ 40);
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
// Bands are [0, 40] rising and [10, 50] falling; they cross at x = 25.
|
||||||
|
const float peak = camera.getPanSpeedTilesPerSecondAt(25.0f, 30.0f);
|
||||||
|
REQUIRE(peak > 10.0f);
|
||||||
|
REQUIRE(peak < 50.0f);
|
||||||
|
|
||||||
|
// And it really is the maximum — the neighbours on both sides are lower.
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(20.0f, 30.0f) < peak);
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(30.0f, 30.0f) < peak);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A zero-width ramp band steps straight from slow to fast", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll(/*rampBandWidth_tiles*/ 0);
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(19.99f, 80.0f) == Approx(10.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(50.0f, 80.0f) == Approx(50.0f));
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(80.01f, 80.0f) == Approx(10.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("The camera starts centered on the world origin", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Panning moves the view center at the local pan speed", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
// Starting at 0, the local speed is the slow one: 10 tiles/s for 500 ms.
|
||||||
|
REQUIRE(camera.advance(PanDirection::Right, 500, makeBounds()));
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(5.0f));
|
||||||
|
|
||||||
|
REQUIRE(camera.advance(PanDirection::Left, 500, makeBounds()));
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Not panning leaves the view center alone", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
camera.advance(PanDirection::Right, 500, makeBounds());
|
||||||
|
const float before = camera.getViewCenterXTiles();
|
||||||
|
|
||||||
|
REQUIRE_FALSE(camera.advance(PanDirection::None, 500, makeBounds()));
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(before));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clamping (REQ-GW-SCROLL-LIMIT)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Panning stops at the scroll bounds", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
// Far more time than it takes to cross the bound.
|
||||||
|
camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(12.0f));
|
||||||
|
|
||||||
|
camera.advance(PanDirection::Left, 100000, makeBounds(-5.0f, 12.0f));
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(-5.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Shrinking bounds pull the view in even without panning", "[camera]")
|
||||||
|
{
|
||||||
|
// Bounds move as the game progresses, so clamping cannot wait for the player
|
||||||
|
// to press a key — a view left outside them would show unreachable world.
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
camera.advance(PanDirection::Right, 100000, makeBounds(-50.0f, 40.0f));
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(40.0f));
|
||||||
|
|
||||||
|
// The right bound comes in; the camera must follow it despite no pan input,
|
||||||
|
// and must report that it moved.
|
||||||
|
REQUIRE(camera.advance(PanDirection::None, 16, makeBounds(-50.0f, 25.0f)));
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(25.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Panning into a bound the view already sits on reports no movement",
|
||||||
|
"[camera]")
|
||||||
|
{
|
||||||
|
// The caller refreshes the box-select rectangle on a true return, so a camera
|
||||||
|
// pinned against its limit must not keep claiming to have moved.
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
camera.advance(PanDirection::Right, 100000, makeBounds(-5.0f, 12.0f));
|
||||||
|
REQUIRE_FALSE(camera.advance(PanDirection::Right, 16, makeBounds(-5.0f, 12.0f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Reset returns the view to the origin", "[camera]")
|
||||||
|
{
|
||||||
|
const WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
camera.advance(PanDirection::Right, 2000, makeBounds());
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() != Approx(0.0f));
|
||||||
|
|
||||||
|
camera.reset();
|
||||||
|
REQUIRE(camera.getViewCenterXTiles() == Approx(0.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The camera tracks config edited after construction", "[camera]")
|
||||||
|
{
|
||||||
|
// The camera references the config rather than copying it, so a restart that
|
||||||
|
// reloads world.toml in place (REQ-CFG-RELOAD) changes the pan speed without
|
||||||
|
// the camera being rebuilt.
|
||||||
|
WorldScroll scroll = makeScroll();
|
||||||
|
const WorldRegions regions = makeRegions();
|
||||||
|
const WorldCamera camera(scroll, regions);
|
||||||
|
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(10.0f));
|
||||||
|
scroll.panSpeedSlow_tps = 3.0;
|
||||||
|
REQUIRE(camera.getPanSpeedTilesPerSecondAt(0.0f, 80.0f) == Approx(3.0f));
|
||||||
|
}
|
||||||
203
src/test/WorldCoordinatesTest.cpp
Normal file
203
src/test/WorldCoordinatesTest.cpp
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QSize>
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
|
// A 800x400 viewport over a 20-tile-high world gives exactly 20 px per tile and a
|
||||||
|
// 40-tile-wide view, so every expectation below is a whole number.
|
||||||
|
static WorldCoordinates makeCoordinates(float viewCenterX_tiles)
|
||||||
|
{
|
||||||
|
return WorldCoordinates::scrolling(QSize(800, 400), 20, viewCenterX_tiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tile size and viewport extent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Tile size makes the world height fill the viewport height", "[coords]")
|
||||||
|
{
|
||||||
|
// REQ-GW-TILE-SIZE: tiles are square and sized so the world height exactly
|
||||||
|
// fills the view's height.
|
||||||
|
REQUIRE(makeCoordinates(0.0f).getTilePx() == Approx(20.0f));
|
||||||
|
REQUIRE(WorldCoordinates::scrolling(QSize(800, 600), 20, 0.0f).getTilePx()
|
||||||
|
== Approx(30.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A degenerate world height falls back to a unit tile", "[coords]")
|
||||||
|
{
|
||||||
|
// Guards the division in every conversion; a zero or negative height would
|
||||||
|
// otherwise produce infinities.
|
||||||
|
REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), 0, 0.0f).getTilePx()
|
||||||
|
== Approx(1.0f));
|
||||||
|
REQUIRE(WorldCoordinates::scrolling(QSize(800, 400), -5, 0.0f).getTilePx()
|
||||||
|
== Approx(1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A zero-size viewport falls back to a unit tile", "[coords]")
|
||||||
|
{
|
||||||
|
// A widget that has not been shown yet still has to answer conversions —
|
||||||
|
// the arena hit-tests through the same transform.
|
||||||
|
REQUIRE(WorldCoordinates::scrolling(QSize(0, 0), 20, 0.0f).getTilePx()
|
||||||
|
== Approx(1.0f));
|
||||||
|
REQUIRE(WorldCoordinates::fitToWorld(QSize(0, 0), 40, 20).getTilePx()
|
||||||
|
== Approx(1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Viewport width in tiles follows the widget width", "[coords]")
|
||||||
|
{
|
||||||
|
REQUIRE(makeCoordinates(0.0f).getViewportWidthTiles() == Approx(40.0f));
|
||||||
|
REQUIRE(WorldCoordinates::scrolling(QSize(400, 400), 20, 0.0f).getViewportWidthTiles()
|
||||||
|
== Approx(20.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("The view left edge is half a viewport left of the center", "[coords]")
|
||||||
|
{
|
||||||
|
REQUIRE(makeCoordinates(0.0f).getViewLeftTiles() == Approx(-20.0f));
|
||||||
|
REQUIRE(makeCoordinates(100.0f).getViewLeftTiles() == Approx(80.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// World <-> widget conversion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("World positions map to widget pixels relative to the view left edge",
|
||||||
|
"[coords]")
|
||||||
|
{
|
||||||
|
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
|
||||||
|
|
||||||
|
REQUIRE(coordinates.worldToWidget(QVector2D(-20.0f, 0.0f)).x() == Approx(0.0));
|
||||||
|
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 0.0f)).x() == Approx(400.0));
|
||||||
|
// Y is not scrolled: world Y maps straight through the tile size.
|
||||||
|
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 3.5f)).y() == Approx(70.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Scrolling right shifts the world left on screen", "[coords]")
|
||||||
|
{
|
||||||
|
const QVector2D worldPos(10.0f, 5.0f);
|
||||||
|
const qreal atOrigin = makeCoordinates(0.0f).worldToWidget(worldPos).x();
|
||||||
|
const qreal scrolled = makeCoordinates(4.0f).worldToWidget(worldPos).x();
|
||||||
|
// Panning the view 4 tiles right moves the same world point 4 tiles (80 px) left.
|
||||||
|
REQUIRE(scrolled == Approx(atOrigin - 80.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A tile's widget position is its top-left corner", "[coords]")
|
||||||
|
{
|
||||||
|
const WorldCoordinates coordinates = makeCoordinates(0.0f);
|
||||||
|
REQUIRE(coordinates.tileToWidget(QPoint(-20, 0)) == QPointF(0.0, 0.0));
|
||||||
|
REQUIRE(coordinates.tileToWidget(QPoint(0, 2)) == QPointF(400.0, 40.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A tile rect covers exactly one tile", "[coords]")
|
||||||
|
{
|
||||||
|
const QRectF rect = makeCoordinates(0.0f).tileRect(QPoint(-19, 1));
|
||||||
|
REQUIRE(rect.left() == Approx(20.0));
|
||||||
|
REQUIRE(rect.top() == Approx(20.0));
|
||||||
|
REQUIRE(rect.width() == Approx(20.0));
|
||||||
|
REQUIRE(rect.height() == Approx(20.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Widget -> world conversion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Widget points map back to the world position they came from", "[coords]")
|
||||||
|
{
|
||||||
|
const WorldCoordinates coordinates = makeCoordinates(7.0f);
|
||||||
|
const QVector2D world = coordinates.widgetToWorld(QPoint(250, 130));
|
||||||
|
const QPointF back = coordinates.worldToWidget(world);
|
||||||
|
REQUIRE(back.x() == Approx(250.0));
|
||||||
|
REQUIRE(back.y() == Approx(130.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Widget points resolve to the tile that contains them", "[coords]")
|
||||||
|
{
|
||||||
|
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
|
||||||
|
|
||||||
|
// Anywhere inside a tile's 20px cell resolves to that tile.
|
||||||
|
REQUIRE(coordinates.widgetToTile(QPoint(0, 0)) == QPoint(-20, 0));
|
||||||
|
REQUIRE(coordinates.widgetToTile(QPoint(19, 19)) == QPoint(-20, 0));
|
||||||
|
REQUIRE(coordinates.widgetToTile(QPoint(20, 20)) == QPoint(-19, 1));
|
||||||
|
REQUIRE(coordinates.widgetToTile(QPoint(405, 45)) == QPoint(0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Tile resolution floors, so negative world positions round down", "[coords]")
|
||||||
|
{
|
||||||
|
// Truncation toward zero would map the whole strip from -1 to 1 onto tile 0,
|
||||||
|
// making the tile under the cursor wrong on the asteroid side (REQ-GW-COORDS:
|
||||||
|
// all asteroid tiles have x < 0).
|
||||||
|
const WorldCoordinates coordinates = makeCoordinates(0.0f); // left edge at -20
|
||||||
|
REQUIRE(coordinates.widgetToTile(QPoint(399, 0)) == QPoint(-1, 0));
|
||||||
|
REQUIRE(coordinates.widgetToTile(QPoint(400, 0)) == QPoint(0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Viewport rect
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("The viewport rect spans the visible tiles with a one-column margin",
|
||||||
|
"[coords]")
|
||||||
|
{
|
||||||
|
// The margin keeps items that straddle an edge from popping in and out.
|
||||||
|
const QRect rect = makeCoordinates(0.0f).getViewportRect(); // left edge at -20
|
||||||
|
REQUIRE(rect.left() == -21);
|
||||||
|
REQUIRE(rect.right() == 20);
|
||||||
|
REQUIRE(rect.top() == 0);
|
||||||
|
REQUIRE(rect.height() == 20); // the full world height
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A fractional scroll position widens the viewport rect outward", "[coords]")
|
||||||
|
{
|
||||||
|
// Left edge at -19.5: the rect must still cover the partially visible columns
|
||||||
|
// on both sides, so it floors on the left and ceils on the right.
|
||||||
|
const QRect rect = makeCoordinates(0.5f).getViewportRect();
|
||||||
|
REQUIRE(rect.left() == -21);
|
||||||
|
REQUIRE(rect.right() == 21);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fitted (non-scrolling) worlds
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("A fitted world takes the tighter of the two axis fits", "[coords]")
|
||||||
|
{
|
||||||
|
// Height-limited: 400/20 = 20 px per tile beats 800/30 = 26.67.
|
||||||
|
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 30, 20).getTilePx()
|
||||||
|
== Approx(20.0f));
|
||||||
|
// Width-limited: 800/80 = 10 px per tile beats 400/20 = 20.
|
||||||
|
REQUIRE(WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20).getTilePx()
|
||||||
|
== Approx(10.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A fitted world keeps its whole width on screen", "[coords]")
|
||||||
|
{
|
||||||
|
// The point of taking the tighter fit: the far edge must land inside the
|
||||||
|
// viewport, never past it.
|
||||||
|
const WorldCoordinates coordinates =
|
||||||
|
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
|
||||||
|
REQUIRE(coordinates.worldToWidget(QVector2D(80.0f, 0.0f)).x() <= 800.0);
|
||||||
|
REQUIRE(coordinates.worldToWidget(QVector2D(0.0f, 20.0f)).y() <= 400.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A fitted world puts the origin at the widget's top-left", "[coords]")
|
||||||
|
{
|
||||||
|
// No scrolling, so there is no view center to subtract.
|
||||||
|
const WorldCoordinates coordinates =
|
||||||
|
WorldCoordinates::fitToWorld(QSize(800, 400), 40, 20);
|
||||||
|
REQUIRE(coordinates.getViewLeftTiles() == Approx(0.0f));
|
||||||
|
REQUIRE(coordinates.tileToWidget(QPoint(0, 0)) == QPointF(0.0, 0.0));
|
||||||
|
REQUIRE(coordinates.tileToWidget(QPoint(3, 2)) == QPointF(60.0, 40.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("A fitted world round-trips widget points back to world positions",
|
||||||
|
"[coords]")
|
||||||
|
{
|
||||||
|
const WorldCoordinates coordinates =
|
||||||
|
WorldCoordinates::fitToWorld(QSize(800, 400), 80, 20);
|
||||||
|
const QVector2D world = coordinates.widgetToWorld(QPoint(120, 55));
|
||||||
|
const QPointF back = coordinates.worldToWidget(world);
|
||||||
|
REQUIRE(back.x() == Approx(120.0));
|
||||||
|
REQUIRE(back.y() == Approx(55.0));
|
||||||
|
}
|
||||||
206
src/ui/BlueprintLibrary.cpp
Normal file
206
src/ui/BlueprintLibrary.cpp
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
#include "BlueprintLibrary.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "BlueprintPlacementRequestedEvent.h"
|
||||||
|
#include "BlueprintSerializer.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
#include "ExitBlueprintModeRequestedEvent.h"
|
||||||
|
|
||||||
|
#include "BuildingConfig.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
|
||||||
|
BlueprintLibrary::BlueprintLibrary(Simulation* sim, const GameConfig* config,
|
||||||
|
QWidget* dialogParent)
|
||||||
|
: m_sim(sim)
|
||||||
|
, m_config(config)
|
||||||
|
, m_dialogParent(dialogParent)
|
||||||
|
{
|
||||||
|
loadFromDisk();
|
||||||
|
registerForEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
BlueprintLibrary::~BlueprintLibrary()
|
||||||
|
{
|
||||||
|
saveToDisk();
|
||||||
|
unregisterForEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BlueprintLibrary::getCanCaptureSelection() const
|
||||||
|
{
|
||||||
|
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
|
||||||
|
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BlueprintLibrary::getHasTemporaryBlueprint() const
|
||||||
|
{
|
||||||
|
return m_temporaryBlueprint.has_value();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::saveSelectionAs(const QString& name)
|
||||||
|
{
|
||||||
|
Blueprint blueprint = createBlueprintFromSelection();
|
||||||
|
if (blueprint.buildings.empty()) { return; }
|
||||||
|
|
||||||
|
blueprint.name = name;
|
||||||
|
m_blueprints.push_back(std::move(blueprint));
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<Blueprint>& BlueprintLibrary::getBlueprints() const
|
||||||
|
{
|
||||||
|
return m_blueprints;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString BlueprintLibrary::getContentsSummary(int index) const
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return QString(); }
|
||||||
|
|
||||||
|
QStringList entries;
|
||||||
|
for (const BlueprintContentEntry& entry : summarizeBlueprintContents(
|
||||||
|
m_blueprints[static_cast<std::size_t>(index)], m_config->buildings))
|
||||||
|
{
|
||||||
|
// The same "<type> x <count>" notation the building multi-selection summary
|
||||||
|
// uses (REQ-UI-MULTI-SELECTION), which also sidesteps plural forms.
|
||||||
|
entries << QObject::tr("%1 x %2")
|
||||||
|
.arg(QString::fromStdString(entry.buildingName))
|
||||||
|
.arg(entry.count);
|
||||||
|
}
|
||||||
|
return entries.join(QStringLiteral(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
int BlueprintLibrary::getCost(int index) const
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return 0; }
|
||||||
|
return computeBlueprintCost(m_blueprints[static_cast<std::size_t>(index)],
|
||||||
|
m_config->buildings);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BlueprintLibrary::getCanAfford(int index) const
|
||||||
|
{
|
||||||
|
return m_sim->getBuildingBlocksStock() >= getCost(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::remove(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
|
||||||
|
|
||||||
|
if (m_activeIndex == index)
|
||||||
|
{
|
||||||
|
m_activeIndex = std::nullopt;
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<ExitBlueprintModeRequestedEvent>());
|
||||||
|
}
|
||||||
|
else if (m_activeIndex.has_value() && *m_activeIndex > index)
|
||||||
|
{
|
||||||
|
--*m_activeIndex;
|
||||||
|
}
|
||||||
|
m_blueprints.erase(m_blueprints.begin() + index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::beginPlacement(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
|
||||||
|
|
||||||
|
m_activeIndex = index;
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintPlacementRequestedEvent>(
|
||||||
|
m_blueprints[static_cast<std::size_t>(index)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
||||||
|
{
|
||||||
|
m_selectedBuildingIds = event->ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
// Only the saved-blueprint index is cleared. A temporary blueprint deliberately
|
||||||
|
// survives its placement mode so V can re-enter it (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
m_activeIndex = std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::handleEvent(
|
||||||
|
std::shared_ptr<const TemporaryBlueprintCaptureRequestedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
// C (REQ-UI-BLUEPRINT-TEMP): capture the current selection and enter placement mode
|
||||||
|
// for it, without naming it, listing it, or persisting it. If nothing player-placeable
|
||||||
|
// is selected, do nothing at all -- in particular, keep the previous temporary
|
||||||
|
// blueprint, which V can still place.
|
||||||
|
Blueprint blueprint = createBlueprintFromSelection();
|
||||||
|
if (blueprint.buildings.empty()) { return; }
|
||||||
|
|
||||||
|
m_temporaryBlueprint = std::move(blueprint);
|
||||||
|
// No saved blueprint is active while a temporary one is being placed.
|
||||||
|
m_activeIndex = std::nullopt;
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintPlacementRequestedEvent>(*m_temporaryBlueprint));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::handleEvent(
|
||||||
|
std::shared_ptr<const TemporaryBlueprintPlaceRequestedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
// V (REQ-UI-BLUEPRINT-TEMP): re-enter placement mode for the temporary blueprint,
|
||||||
|
// capturing nothing. With none captured, nothing happens -- no mode is entered and
|
||||||
|
// whichever mode is active is left alone. Affordability is not checked here, matching
|
||||||
|
// C; cost is enforced at placement (REQ-UI-BLUEPRINT-PLACE). The blueprint is copied,
|
||||||
|
// not moved: it stays available for the next V.
|
||||||
|
if (!m_temporaryBlueprint.has_value()) { return; }
|
||||||
|
|
||||||
|
m_activeIndex = std::nullopt;
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintPlacementRequestedEvent>(*m_temporaryBlueprint));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::handleEvent(std::shared_ptr<const GameResetEvent> /*event*/)
|
||||||
|
{
|
||||||
|
// A restart begins a new run, and the temporary blueprint belongs to the old one
|
||||||
|
// (REQ-UI-BLUEPRINT-TEMP). The saved blueprints are not run state and stay.
|
||||||
|
m_temporaryBlueprint = std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
Blueprint BlueprintLibrary::createBlueprintFromSelection() const
|
||||||
|
{
|
||||||
|
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
|
||||||
|
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
|
||||||
|
// REQ-UI-BLUEPRINT-STORAGE).
|
||||||
|
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::loadFromDisk()
|
||||||
|
{
|
||||||
|
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
|
||||||
|
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
|
||||||
|
}
|
||||||
|
catch (const std::exception& e)
|
||||||
|
{
|
||||||
|
QMessageBox::critical(m_dialogParent, QObject::tr("Load Failed"),
|
||||||
|
QObject::tr("Failed to load blueprints:\n%1").arg(e.what()));
|
||||||
|
m_blueprints.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintLibrary::saveToDisk() const
|
||||||
|
{
|
||||||
|
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
|
||||||
|
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const std::string content = BlueprintSerializer::serialize(m_blueprints);
|
||||||
|
file.write(QByteArray::fromStdString(content));
|
||||||
|
}
|
||||||
|
catch (...) {}
|
||||||
|
}
|
||||||
106
src/ui/BlueprintLibrary.h
Normal file
106
src/ui/BlueprintLibrary.h
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "Blueprint.h"
|
||||||
|
#include "BlueprintModeExitedEvent.h"
|
||||||
|
#include "BuildingId.h"
|
||||||
|
#include "EventHandler.h"
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "GameResetEvent.h"
|
||||||
|
#include "SelectionChangedEvent.h"
|
||||||
|
#include "TemporaryBlueprintCaptureRequestedEvent.h"
|
||||||
|
#include "TemporaryBlueprintPlaceRequestedEvent.h"
|
||||||
|
|
||||||
|
class Simulation;
|
||||||
|
class QWidget;
|
||||||
|
|
||||||
|
// The player's saved blueprints: the list itself, its persistence (REQ-UI-BLUEPRINT-SAVE,
|
||||||
|
// REQ-UI-BLUEPRINT-LOAD), capture from the current selection (REQ-UI-BLUEPRINT-CREATE,
|
||||||
|
// REQ-UI-BLUEPRINT-TEMP), and entry into blueprint placement mode.
|
||||||
|
//
|
||||||
|
// Deliberately not a widget: blueprints have no permanent place on screen any more
|
||||||
|
// (REQ-UI-BLUEPRINT-DIALOG), and the modal dialogs that present them must be driven from
|
||||||
|
// MainWindow, the only widget that can pause the game (ModalPauseScope) and raise the dim
|
||||||
|
// overlay. This class is the model those dialogs read and mutate.
|
||||||
|
class BlueprintLibrary
|
||||||
|
: public CombinedEventHandler<SelectionChangedEvent,
|
||||||
|
BlueprintModeExitedEvent,
|
||||||
|
TemporaryBlueprintCaptureRequestedEvent,
|
||||||
|
TemporaryBlueprintPlaceRequestedEvent,
|
||||||
|
GameResetEvent>
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// dialogParent parents the load-failure message box (REQ-UI-BLUEPRINT-LOAD) and is
|
||||||
|
// not owned.
|
||||||
|
BlueprintLibrary(Simulation* sim, const GameConfig* config, QWidget* dialogParent);
|
||||||
|
~BlueprintLibrary();
|
||||||
|
|
||||||
|
// True when the current selection holds at least one player-placeable building or
|
||||||
|
// construction site -- the condition under which Ctrl+C has any effect
|
||||||
|
// (REQ-UI-BLUEPRINT-CREATE).
|
||||||
|
bool getCanCaptureSelection() const;
|
||||||
|
|
||||||
|
// True once C has captured something -- the condition under which V does anything
|
||||||
|
// (REQ-UI-BLUEPRINT-TEMP), and so the condition under which the controls panel
|
||||||
|
// offers it (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
bool getHasTemporaryBlueprint() const;
|
||||||
|
|
||||||
|
// Captures the current selection under the given name and appends it to the list.
|
||||||
|
// Silently does nothing when nothing player-placeable is selected.
|
||||||
|
void saveSelectionAs(const QString& name);
|
||||||
|
|
||||||
|
const std::vector<Blueprint>& getBlueprints() const;
|
||||||
|
|
||||||
|
// The blueprint's contents line, ready for its card: one "<name> x <count>" entry
|
||||||
|
// per building type, comma-separated, ordered by summarizeBlueprintContents
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
QString getContentsSummary(int index) const;
|
||||||
|
|
||||||
|
// Sum of the constituent buildings' placement costs (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
int getCost(int index) const;
|
||||||
|
|
||||||
|
// Whether the player can currently afford the blueprint's total cost, which is what
|
||||||
|
// enables or greys out its card (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
bool getCanAfford(int index) const;
|
||||||
|
|
||||||
|
// Removes the blueprint, exiting blueprint placement mode if it was the active one
|
||||||
|
// (REQ-UI-BLUEPRINT-DELETE).
|
||||||
|
void remove(int index);
|
||||||
|
|
||||||
|
// Enters blueprint placement mode for the blueprint (REQ-UI-BLUEPRINT-MODE).
|
||||||
|
void beginPlacement(int index);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
|
||||||
|
void handleEvent(
|
||||||
|
std::shared_ptr<const TemporaryBlueprintCaptureRequestedEvent> event) override;
|
||||||
|
void handleEvent(
|
||||||
|
std::shared_ptr<const TemporaryBlueprintPlaceRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const GameResetEvent> event) override;
|
||||||
|
|
||||||
|
Blueprint createBlueprintFromSelection() const;
|
||||||
|
void loadFromDisk();
|
||||||
|
void saveToDisk() const;
|
||||||
|
|
||||||
|
// The simulation is the single source of truth for the block stock and the
|
||||||
|
// selection; the change events are only refresh signals.
|
||||||
|
Simulation* m_sim;
|
||||||
|
const GameConfig* m_config;
|
||||||
|
QWidget* m_dialogParent;
|
||||||
|
std::vector<BuildingId> m_selectedBuildingIds;
|
||||||
|
std::vector<Blueprint> m_blueprints;
|
||||||
|
// Index of the blueprint currently in placement mode, so deleting it can exit that
|
||||||
|
// mode (REQ-UI-BLUEPRINT-DELETE). nullopt = no saved blueprint is being placed.
|
||||||
|
std::optional<int> m_activeIndex;
|
||||||
|
// The one unnamed blueprint captured with C and re-placed with V
|
||||||
|
// (REQ-UI-BLUEPRINT-TEMP). Deliberately kept out of m_blueprints: it is never named,
|
||||||
|
// never listed in the selection dialog, and never written to blueprints.toml. It
|
||||||
|
// outlives its placement mode, so V can re-enter it. nullopt = none captured since
|
||||||
|
// startup or the last restart.
|
||||||
|
std::optional<Blueprint> m_temporaryBlueprint;
|
||||||
|
};
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
#include "BlueprintPanel.h"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <climits>
|
|
||||||
|
|
||||||
#include <QCoreApplication>
|
|
||||||
#include <QFile>
|
|
||||||
#include <QHBoxLayout>
|
|
||||||
#include <QInputDialog>
|
|
||||||
#include <QMessageBox>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QScrollArea>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
|
|
||||||
#include "BlueprintPlacementRequestedEvent.h"
|
|
||||||
#include "BlueprintSerializer.h"
|
|
||||||
#include "BuildingBlocksChangedEvent.h"
|
|
||||||
#include "EventManager.h"
|
|
||||||
#include "ExitBlueprintModeRequestedEvent.h"
|
|
||||||
|
|
||||||
#include "Building.h"
|
|
||||||
#include "BuildingConfig.h"
|
|
||||||
#include "BuildingSystem.h"
|
|
||||||
#include "Simulation.h"
|
|
||||||
|
|
||||||
BlueprintPanel::BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent)
|
|
||||||
: QWidget(parent)
|
|
||||||
, m_sim(sim)
|
|
||||||
, m_config(config)
|
|
||||||
{
|
|
||||||
QVBoxLayout* layout = new QVBoxLayout(this);
|
|
||||||
layout->setContentsMargins(4, 4, 4, 4);
|
|
||||||
layout->setSpacing(4);
|
|
||||||
|
|
||||||
m_createBtn = new QPushButton(tr("Create Blueprint"), this);
|
|
||||||
m_createBtn->setFixedHeight(48);
|
|
||||||
m_createBtn->setEnabled(false);
|
|
||||||
layout->addWidget(m_createBtn);
|
|
||||||
|
|
||||||
QScrollArea* scrollArea = new QScrollArea(this);
|
|
||||||
scrollArea->setWidgetResizable(true);
|
|
||||||
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
||||||
|
|
||||||
m_buttonsContainer = new QWidget(scrollArea);
|
|
||||||
m_buttonsLayout = new QVBoxLayout(m_buttonsContainer);
|
|
||||||
m_buttonsLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
m_buttonsLayout->setSpacing(4);
|
|
||||||
m_buttonsLayout->addStretch();
|
|
||||||
|
|
||||||
scrollArea->setWidget(m_buttonsContainer);
|
|
||||||
layout->addWidget(scrollArea, 1);
|
|
||||||
|
|
||||||
connect(m_createBtn, &QPushButton::clicked, this, &BlueprintPanel::onCreateClicked);
|
|
||||||
|
|
||||||
loadFromDisk();
|
|
||||||
rebuildButtons();
|
|
||||||
|
|
||||||
registerForEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
BlueprintPanel::~BlueprintPanel()
|
|
||||||
{
|
|
||||||
saveToDisk();
|
|
||||||
unregisterForEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::onSelectionChanged(const std::vector<BuildingId>& ids)
|
|
||||||
{
|
|
||||||
m_selectedBuildingIds = ids;
|
|
||||||
refreshButtonStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
refreshButtonStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::clearActiveBlueprintButton()
|
|
||||||
{
|
|
||||||
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
|
|
||||||
{
|
|
||||||
m_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
|
|
||||||
}
|
|
||||||
m_activeIndex = std::nullopt;
|
|
||||||
refreshButtonStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::onCreateClicked()
|
|
||||||
{
|
|
||||||
if (m_selectedBuildingIds.empty()) { return; }
|
|
||||||
|
|
||||||
Blueprint bp = createBlueprintFromSelection();
|
|
||||||
if (bp.buildings.empty()) { return; }
|
|
||||||
|
|
||||||
bool ok = false;
|
|
||||||
const QString name = QInputDialog::getText(
|
|
||||||
this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal, QString(), &ok);
|
|
||||||
if (!ok || name.trimmed().isEmpty()) { return; }
|
|
||||||
|
|
||||||
bp.name = name.trimmed();
|
|
||||||
m_blueprints.push_back(bp);
|
|
||||||
rebuildButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::onDeleteBlueprintClicked(int index)
|
|
||||||
{
|
|
||||||
if (m_activeIndex == index)
|
|
||||||
{
|
|
||||||
m_activeIndex = std::nullopt;
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<ExitBlueprintModeRequestedEvent>());
|
|
||||||
}
|
|
||||||
else if (m_activeIndex.has_value() && *m_activeIndex > index)
|
|
||||||
{
|
|
||||||
--*m_activeIndex;
|
|
||||||
}
|
|
||||||
m_blueprints.erase(m_blueprints.begin() + index);
|
|
||||||
rebuildButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::onBlueprintButtonClicked(int index)
|
|
||||||
{
|
|
||||||
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
|
|
||||||
|
|
||||||
if (m_activeIndex == index)
|
|
||||||
{
|
|
||||||
clearActiveBlueprintButton();
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<ExitBlueprintModeRequestedEvent>());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_activeIndex.has_value() && *m_activeIndex < static_cast<int>(m_blueprintButtons.size()))
|
|
||||||
{
|
|
||||||
m_blueprintButtons[static_cast<std::size_t>(*m_activeIndex)]->setChecked(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
m_activeIndex = index;
|
|
||||||
m_blueprintButtons[static_cast<std::size_t>(index)]->setChecked(true);
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<BlueprintPlacementRequestedEvent>(m_blueprints[static_cast<std::size_t>(index)]));
|
|
||||||
}
|
|
||||||
|
|
||||||
Blueprint BlueprintPanel::createBlueprintFromSelection() const
|
|
||||||
{
|
|
||||||
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
|
|
||||||
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
|
|
||||||
// REQ-UI-BLUEPRINT-STORAGE).
|
|
||||||
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
int BlueprintPanel::computeBlueprintCost(const Blueprint& bp) const
|
|
||||||
{
|
|
||||||
int total = 0;
|
|
||||||
for (const BlueprintBuilding& bb : bp.buildings)
|
|
||||||
{
|
|
||||||
const BuildingDef* def = m_config->buildings.findBuildingDef(bb.type);
|
|
||||||
if (def) { total += def->cost; }
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::rebuildButtons()
|
|
||||||
{
|
|
||||||
while (m_buttonsLayout->count() > 1)
|
|
||||||
{
|
|
||||||
QLayoutItem* item = m_buttonsLayout->takeAt(0);
|
|
||||||
if (item->widget()) { delete item->widget(); }
|
|
||||||
delete item;
|
|
||||||
}
|
|
||||||
m_blueprintButtons.clear();
|
|
||||||
|
|
||||||
for (int i = 0; i < static_cast<int>(m_blueprints.size()); ++i)
|
|
||||||
{
|
|
||||||
const Blueprint& bp = m_blueprints[static_cast<std::size_t>(i)];
|
|
||||||
const int cost = computeBlueprintCost(bp);
|
|
||||||
const QString label = bp.name + "\n" + tr("%1 Building Blocks").arg(cost);
|
|
||||||
|
|
||||||
QWidget* row = new QWidget(m_buttonsContainer);
|
|
||||||
QHBoxLayout* rowLayout = new QHBoxLayout(row);
|
|
||||||
rowLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
rowLayout->setSpacing(4);
|
|
||||||
|
|
||||||
QPushButton* btn = new QPushButton(label, row);
|
|
||||||
btn->setCheckable(true);
|
|
||||||
btn->setFixedHeight(48);
|
|
||||||
|
|
||||||
QPushButton* delBtn = new QPushButton("\xc3\x97", row);
|
|
||||||
delBtn->setFixedWidth(28);
|
|
||||||
delBtn->setFixedHeight(48);
|
|
||||||
|
|
||||||
rowLayout->addWidget(btn, 1);
|
|
||||||
rowLayout->addWidget(delBtn, 0);
|
|
||||||
|
|
||||||
m_buttonsLayout->insertWidget(i, row);
|
|
||||||
|
|
||||||
const int capturedIndex = i;
|
|
||||||
connect(btn, &QPushButton::clicked, this, [this, capturedIndex]() {
|
|
||||||
onBlueprintButtonClicked(capturedIndex);
|
|
||||||
});
|
|
||||||
connect(delBtn, &QPushButton::clicked, this, [this, capturedIndex]() {
|
|
||||||
onDeleteBlueprintClicked(capturedIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
m_blueprintButtons.push_back(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshButtonStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::saveToDisk() const
|
|
||||||
{
|
|
||||||
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
|
|
||||||
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const std::string content = BlueprintSerializer::serialize(m_blueprints);
|
|
||||||
file.write(QByteArray::fromStdString(content));
|
|
||||||
}
|
|
||||||
catch (...) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::loadFromDisk()
|
|
||||||
{
|
|
||||||
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
|
|
||||||
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
|
|
||||||
try
|
|
||||||
{
|
|
||||||
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
|
|
||||||
}
|
|
||||||
catch (const std::exception& e)
|
|
||||||
{
|
|
||||||
QMessageBox::critical(this, tr("Load Failed"),
|
|
||||||
tr("Failed to load blueprints:\n%1").arg(e.what()));
|
|
||||||
m_blueprints.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::refreshButtonStates()
|
|
||||||
{
|
|
||||||
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
|
|
||||||
m_createBtn->setEnabled(selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds));
|
|
||||||
|
|
||||||
const int blocks = m_sim->getBuildingBlocksStock();
|
|
||||||
|
|
||||||
for (int i = 0; i < static_cast<int>(m_blueprintButtons.size()); ++i)
|
|
||||||
{
|
|
||||||
const int cost = computeBlueprintCost(m_blueprints[static_cast<std::size_t>(i)]);
|
|
||||||
const bool canAfford = blocks >= cost;
|
|
||||||
m_blueprintButtons[static_cast<std::size_t>(i)]->setEnabled(
|
|
||||||
canAfford || m_activeIndex == i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
|
||||||
{
|
|
||||||
onSelectionChanged(event->ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
clearActiveBlueprintButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BlueprintPanel::handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
// Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and
|
|
||||||
// enter placement mode without adding it to the list or persisting it. If nothing
|
|
||||||
// player-placeable is selected, do nothing.
|
|
||||||
Blueprint bp = createBlueprintFromSelection();
|
|
||||||
if (bp.buildings.empty()) { return; }
|
|
||||||
|
|
||||||
// No saved blueprint is active while a temporary one is being placed.
|
|
||||||
clearActiveBlueprintButton();
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<BlueprintPlacementRequestedEvent>(std::move(bp)));
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <optional>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
||||||
#include "Blueprint.h"
|
|
||||||
#include "BlueprintModeExitedEvent.h"
|
|
||||||
#include "BuildingBlocksChangedEvent.h"
|
|
||||||
#include "BuildingId.h"
|
|
||||||
#include "EventHandler.h"
|
|
||||||
#include "GameConfig.h"
|
|
||||||
#include "SelectionChangedEvent.h"
|
|
||||||
#include "TemporaryBlueprintRequestedEvent.h"
|
|
||||||
#include "Tick.h"
|
|
||||||
|
|
||||||
class Simulation;
|
|
||||||
class QPushButton;
|
|
||||||
class QScrollArea;
|
|
||||||
class QVBoxLayout;
|
|
||||||
|
|
||||||
class BlueprintPanel : public QWidget,
|
|
||||||
public CombinedEventHandler<BuildingBlocksChangedEvent,
|
|
||||||
SelectionChangedEvent,
|
|
||||||
BlueprintModeExitedEvent,
|
|
||||||
TemporaryBlueprintRequestedEvent>
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
BlueprintPanel(Simulation* sim, const GameConfig* config, QWidget* parent = nullptr);
|
|
||||||
~BlueprintPanel() override;
|
|
||||||
|
|
||||||
private:
|
|
||||||
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> event) override;
|
|
||||||
|
|
||||||
private slots:
|
|
||||||
void onCreateClicked();
|
|
||||||
void onDeleteBlueprintClicked(int index);
|
|
||||||
void onBlueprintButtonClicked(int index);
|
|
||||||
|
|
||||||
private:
|
|
||||||
void onSelectionChanged(const std::vector<BuildingId>& ids);
|
|
||||||
void clearActiveBlueprintButton();
|
|
||||||
Blueprint createBlueprintFromSelection() const;
|
|
||||||
int computeBlueprintCost(const Blueprint& bp) const;
|
|
||||||
void rebuildButtons();
|
|
||||||
void refreshButtonStates();
|
|
||||||
void loadFromDisk();
|
|
||||||
void saveToDisk() const;
|
|
||||||
|
|
||||||
// The simulation is the single source of truth for the block stock; the
|
|
||||||
// change event is only a refresh signal.
|
|
||||||
Simulation* m_sim;
|
|
||||||
const GameConfig* m_config;
|
|
||||||
std::vector<BuildingId> m_selectedBuildingIds;
|
|
||||||
std::optional<int> m_activeIndex; // nullopt = no blueprint selected
|
|
||||||
std::vector<Blueprint> m_blueprints;
|
|
||||||
std::vector<QPushButton*> m_blueprintButtons;
|
|
||||||
QPushButton* m_createBtn;
|
|
||||||
QWidget* m_buttonsContainer;
|
|
||||||
QVBoxLayout* m_buttonsLayout;
|
|
||||||
};
|
|
||||||
333
src/ui/BlueprintSelectionDialog.cpp
Normal file
333
src/ui/BlueprintSelectionDialog.cpp
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
#include "BlueprintSelectionDialog.h"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QChar>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFontMetrics>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLayoutItem>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QSize>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStyle>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "Blueprint.h"
|
||||||
|
#include "BlueprintLibrary.h"
|
||||||
|
#include "IconCaption.h"
|
||||||
|
#include "ItemIconCache.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Fixed at two, per REQ-UI-BLUEPRINT-DIALOG; the grid scrolls rather than reflowing.
|
||||||
|
const int kGridColumnCount = 2;
|
||||||
|
|
||||||
|
// Card geometry. Every card is the same size so the grid stays even
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD); the width is chosen to fit a name and a short contents
|
||||||
|
// line, and the height follows from three text rows.
|
||||||
|
const int kCardWidthPx = 220;
|
||||||
|
const int kCardPaddingPx = 10;
|
||||||
|
const int kCardRowGapPx = 4;
|
||||||
|
|
||||||
|
// Side length of the per-card delete icon and of the dialog's close button.
|
||||||
|
const int kSmallButtonSizePx = 22;
|
||||||
|
|
||||||
|
// Spacing between cards, and the dialog's own content margin.
|
||||||
|
const int kSpacingPx = 8;
|
||||||
|
|
||||||
|
// Card rows visible before the grid scrolls. The half row is deliberate: a cut-off
|
||||||
|
// card reads as "there is more below" at a glance.
|
||||||
|
const double kVisibleRowCount = 2.5;
|
||||||
|
|
||||||
|
// U+00D7 MULTIPLICATION SIGN, the close and delete glyph. Written as a code point
|
||||||
|
// because the sources are not guaranteed to be read as UTF-8 by every compiler.
|
||||||
|
const QChar kCrossGlyph(0x00D7);
|
||||||
|
|
||||||
|
// Inner size of a card's composed face: the name row, the contents row, and the
|
||||||
|
// cost row, plus the gap above the cost.
|
||||||
|
QSize getCardFaceSize(const QFont& font)
|
||||||
|
{
|
||||||
|
const int rowHeight = QFontMetrics(font).height();
|
||||||
|
return QSize(kCardWidthPx - 2 * kCardPaddingPx, rowHeight * 3 + kCardRowGapPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize getCardSize(const QFont& font)
|
||||||
|
{
|
||||||
|
const QSize faceSize = getCardFaceSize(font);
|
||||||
|
return QSize(faceSize.width() + 2 * kCardPaddingPx,
|
||||||
|
faceSize.height() + 2 * kCardPaddingPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One card face: the blueprint name, its contents line below in a dimmed color,
|
||||||
|
// and the cost with its block icon at the bottom (REQ-UI-BLUEPRINT-CARD). The three
|
||||||
|
// are composed into a single pixmap because a QPushButton holds only one icon --
|
||||||
|
// the same device the build button faces use.
|
||||||
|
QPixmap composeCardFace(const QString& name, const QString& contents,
|
||||||
|
const QPixmap& cost, const QFont& font,
|
||||||
|
const QColor& textColor, const QColor& dimColor,
|
||||||
|
const QSize& faceSize)
|
||||||
|
{
|
||||||
|
const QFontMetrics metrics(font);
|
||||||
|
const int rowHeight = metrics.height();
|
||||||
|
|
||||||
|
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
|
||||||
|
QPixmap face(static_cast<int>(faceSize.width() * dpr),
|
||||||
|
static_cast<int>(faceSize.height() * dpr));
|
||||||
|
face.setDevicePixelRatio(dpr);
|
||||||
|
face.fill(Qt::transparent);
|
||||||
|
|
||||||
|
QPainter painter(&face);
|
||||||
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||||
|
painter.setFont(font);
|
||||||
|
|
||||||
|
// Both text rows are elided rather than wrapped or shrunk, so a long name or a
|
||||||
|
// many-typed blueprint cannot change a card's height (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
painter.setPen(textColor);
|
||||||
|
painter.drawText(QRect(0, 0, faceSize.width(), rowHeight),
|
||||||
|
Qt::AlignLeft | Qt::AlignVCenter,
|
||||||
|
metrics.elidedText(name, Qt::ElideRight, faceSize.width()));
|
||||||
|
|
||||||
|
painter.setPen(dimColor);
|
||||||
|
painter.drawText(QRect(0, rowHeight, faceSize.width(), rowHeight),
|
||||||
|
Qt::AlignLeft | Qt::AlignVCenter,
|
||||||
|
metrics.elidedText(contents, Qt::ElideRight, faceSize.width()));
|
||||||
|
|
||||||
|
const QSize costSize = getLogicalSize(cost);
|
||||||
|
painter.drawPixmap(0, faceSize.height() - costSize.height(), cost);
|
||||||
|
return face;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A composed card face and the size to show it at; the button needs both, and only
|
||||||
|
// the composer knows the size it arrived at.
|
||||||
|
struct CardFace { QIcon icon; QSize size; };
|
||||||
|
|
||||||
|
// The two-mode face of one card. The modes differ only in the text color, so an
|
||||||
|
// unaffordable card greys itself when Qt swaps the pixmap on the disabled button
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD, consistent with REQ-UI-BUILD-DISABLED).
|
||||||
|
CardFace buildCardFace(const QString& name, const QString& contents, int cost,
|
||||||
|
const QPixmap& blockIcon, const QFont& font,
|
||||||
|
const QPalette& palette)
|
||||||
|
{
|
||||||
|
const QSize faceSize = getCardFaceSize(font);
|
||||||
|
const QColor dimColor = palette.color(QPalette::Disabled, QPalette::ButtonText);
|
||||||
|
|
||||||
|
CardFace result;
|
||||||
|
for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled })
|
||||||
|
{
|
||||||
|
const QColor textColor = palette.color(
|
||||||
|
(mode == QIcon::Normal) ? QPalette::Active : QPalette::Disabled,
|
||||||
|
QPalette::ButtonText);
|
||||||
|
// The cost reads exactly like the header stock and the build button costs:
|
||||||
|
// the number, then the block icon in place of a trailing "Blocks"
|
||||||
|
// (REQ-UI-BLOCKS-ICON). A null icon leaves the bare number.
|
||||||
|
const QPixmap costPixmap =
|
||||||
|
renderCaptionWithIcon(QString::number(cost), blockIcon, font, textColor);
|
||||||
|
const QPixmap face = composeCardFace(name, contents, costPixmap, font,
|
||||||
|
textColor, dimColor, faceSize);
|
||||||
|
result.icon.addPixmap(face, mode);
|
||||||
|
result.size = result.size.expandedTo(getLogicalSize(face));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
|
||||||
|
ItemIconCache* itemIcons,
|
||||||
|
QWidget* parent)
|
||||||
|
: QDialog(parent)
|
||||||
|
, m_library(library)
|
||||||
|
, m_itemIcons(itemIcons)
|
||||||
|
{
|
||||||
|
setWindowTitle(tr("Blueprints"));
|
||||||
|
setModal(true);
|
||||||
|
// Frameless: the dialog draws its own header row, so an OS title bar would only
|
||||||
|
// repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
|
||||||
|
// rounding a top-level window needs a translucent background, which is unreliable
|
||||||
|
// on Windows. The border matches the build bar and the selection panel.
|
||||||
|
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
|
||||||
|
setAttribute(Qt::WA_StyledBackground, true);
|
||||||
|
setStyleSheet(QStringLiteral(
|
||||||
|
"BlueprintSelectionDialog { background-color: palette(window);"
|
||||||
|
" border: 1px solid palette(mid); }"));
|
||||||
|
|
||||||
|
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
||||||
|
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
|
||||||
|
mainLayout->setSpacing(kSpacingPx);
|
||||||
|
|
||||||
|
QHBoxLayout* headerLayout = new QHBoxLayout();
|
||||||
|
headerLayout->setSpacing(kSpacingPx);
|
||||||
|
|
||||||
|
QLabel* titleLabel = new QLabel(tr("Blueprints"), this);
|
||||||
|
QFont headerFont = titleLabel->font();
|
||||||
|
headerFont.setBold(true);
|
||||||
|
titleLabel->setFont(headerFont);
|
||||||
|
headerLayout->addWidget(titleLabel);
|
||||||
|
|
||||||
|
// Dimmed and bold, the treatment the build button hotkey badges use, so the
|
||||||
|
// shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST).
|
||||||
|
QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this);
|
||||||
|
hotkeyBadge->setFont(headerFont);
|
||||||
|
QPalette badgePalette = hotkeyBadge->palette();
|
||||||
|
badgePalette.setColor(hotkeyBadge->foregroundRole(),
|
||||||
|
palette().color(QPalette::Disabled, QPalette::WindowText));
|
||||||
|
hotkeyBadge->setPalette(badgePalette);
|
||||||
|
headerLayout->addWidget(hotkeyBadge);
|
||||||
|
|
||||||
|
headerLayout->addStretch();
|
||||||
|
|
||||||
|
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
|
||||||
|
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
|
||||||
|
closeButton->setToolTip(tr("Close"));
|
||||||
|
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
|
||||||
|
headerLayout->addWidget(closeButton);
|
||||||
|
|
||||||
|
mainLayout->addLayout(headerLayout);
|
||||||
|
|
||||||
|
QScrollArea* scrollArea = new QScrollArea(this);
|
||||||
|
scrollArea->setWidgetResizable(true);
|
||||||
|
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
scrollArea->setFrameShape(QFrame::NoFrame);
|
||||||
|
|
||||||
|
m_gridContainer = new QWidget(scrollArea);
|
||||||
|
m_grid = new QGridLayout(m_gridContainer);
|
||||||
|
m_grid->setContentsMargins(0, 0, 0, 0);
|
||||||
|
m_grid->setSpacing(kSpacingPx);
|
||||||
|
m_grid->setAlignment(Qt::AlignTop | Qt::AlignLeft);
|
||||||
|
scrollArea->setWidget(m_gridContainer);
|
||||||
|
mainLayout->addWidget(scrollArea, 1);
|
||||||
|
|
||||||
|
rebuildGrid();
|
||||||
|
|
||||||
|
// Fixed size (REQ-UI-BLUEPRINT-DIALOG), sized to show kVisibleRowCount card rows.
|
||||||
|
const QSize cardSize = getCardSize(font());
|
||||||
|
const int gridWidth = kGridColumnCount * cardSize.width()
|
||||||
|
+ (kGridColumnCount - 1) * kSpacingPx;
|
||||||
|
const int gridHeight = static_cast<int>(
|
||||||
|
cardSize.height() * kVisibleRowCount + kSpacingPx * (kVisibleRowCount - 1.0));
|
||||||
|
setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent)
|
||||||
|
+ 2 * kSpacingPx,
|
||||||
|
gridHeight + kSmallButtonSizePx + 3 * kSpacingPx);
|
||||||
|
|
||||||
|
// A frameless dialog does not get Qt's automatic centering on its parent.
|
||||||
|
if (parent)
|
||||||
|
{
|
||||||
|
const QRect parentRect = parent->window()->geometry();
|
||||||
|
move(parentRect.center() - QPoint(width() / 2, height() / 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<int> BlueprintSelectionDialog::getChosenIndex() const
|
||||||
|
{
|
||||||
|
return m_chosenIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintSelectionDialog::rebuildGrid()
|
||||||
|
{
|
||||||
|
// deleteLater, not delete: this runs from a delete button's own clicked signal, and
|
||||||
|
// destroying that button inside its emission would leave Qt holding a dangling
|
||||||
|
// sender. hide() makes the removal immediate all the same.
|
||||||
|
while (QLayoutItem* item = m_grid->takeAt(0))
|
||||||
|
{
|
||||||
|
if (item->widget())
|
||||||
|
{
|
||||||
|
item->widget()->hide();
|
||||||
|
item->widget()->deleteLater();
|
||||||
|
}
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<Blueprint>& blueprints = m_library->getBlueprints();
|
||||||
|
if (blueprints.empty())
|
||||||
|
{
|
||||||
|
QLabel* emptyLabel = new QLabel(
|
||||||
|
tr("No blueprints yet.\n\nSelect one or more buildings and press Ctrl+C "
|
||||||
|
"to save them as a blueprint."),
|
||||||
|
m_gridContainer);
|
||||||
|
emptyLabel->setAlignment(Qt::AlignCenter);
|
||||||
|
emptyLabel->setWordWrap(true);
|
||||||
|
m_grid->addWidget(emptyLabel, 0, 0, 1, kGridColumnCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); null when no
|
||||||
|
// building_block icon exists, in which case the cost is the bare number.
|
||||||
|
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
|
||||||
|
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
|
||||||
|
: QPixmap();
|
||||||
|
|
||||||
|
const QSize cardSize = getCardSize(font());
|
||||||
|
|
||||||
|
for (int i = 0; i < static_cast<int>(blueprints.size()); ++i)
|
||||||
|
{
|
||||||
|
const Blueprint& blueprint = blueprints[static_cast<std::size_t>(i)];
|
||||||
|
const CardFace face = buildCardFace(
|
||||||
|
blueprint.name, m_library->getContentsSummary(i), m_library->getCost(i),
|
||||||
|
blockIcon, font(), palette());
|
||||||
|
|
||||||
|
QWidget* card = new QWidget(m_gridContainer);
|
||||||
|
card->setFixedSize(cardSize);
|
||||||
|
|
||||||
|
QVBoxLayout* cardLayout = new QVBoxLayout(card);
|
||||||
|
cardLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
|
||||||
|
QPushButton* body = new QPushButton(card);
|
||||||
|
body->setIcon(face.icon);
|
||||||
|
body->setIconSize(face.size);
|
||||||
|
// An unaffordable card is greyed and does not respond, but its delete icon
|
||||||
|
// stays live (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
body->setEnabled(m_library->getCanAfford(i));
|
||||||
|
cardLayout->addWidget(body);
|
||||||
|
|
||||||
|
// A sibling of the body rather than one of its children, and outside the
|
||||||
|
// layout: Qt disables a widget's children along with it, and the delete icon
|
||||||
|
// must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE).
|
||||||
|
QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card);
|
||||||
|
deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
|
||||||
|
deleteButton->setToolTip(tr("Delete blueprint"));
|
||||||
|
deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2,
|
||||||
|
cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2);
|
||||||
|
deleteButton->raise();
|
||||||
|
|
||||||
|
m_grid->addWidget(card, i / kGridColumnCount, i % kGridColumnCount);
|
||||||
|
|
||||||
|
const int index = i;
|
||||||
|
connect(body, &QPushButton::clicked, this, [this, index]()
|
||||||
|
{
|
||||||
|
onCardClicked(index);
|
||||||
|
});
|
||||||
|
connect(deleteButton, &QPushButton::clicked, this, [this, index]()
|
||||||
|
{
|
||||||
|
onDeleteClicked(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintSelectionDialog::onCardClicked(int index)
|
||||||
|
{
|
||||||
|
// Placement mode is entered by the caller once the dialog has closed
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD).
|
||||||
|
m_chosenIndex = index;
|
||||||
|
accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BlueprintSelectionDialog::onDeleteClicked(int index)
|
||||||
|
{
|
||||||
|
// No confirmation prompt, and the dialog stays open (REQ-UI-BLUEPRINT-DELETE).
|
||||||
|
m_library->remove(index);
|
||||||
|
rebuildGrid();
|
||||||
|
}
|
||||||
42
src/ui/BlueprintSelectionDialog.h
Normal file
42
src/ui/BlueprintSelectionDialog.h
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
|
||||||
|
class BlueprintLibrary;
|
||||||
|
class ItemIconCache;
|
||||||
|
class QGridLayout;
|
||||||
|
class QWidget;
|
||||||
|
|
||||||
|
// The blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG): a frameless modal panel
|
||||||
|
// showing every saved blueprint as a card in a scrolling two-column grid. The caller
|
||||||
|
// pauses the game and raises the dim overlay while it is open.
|
||||||
|
//
|
||||||
|
// Clicking a card accepts the dialog and reports that blueprint's index; the caller
|
||||||
|
// enters placement mode afterwards, so the dialog is already closed by then
|
||||||
|
// (REQ-UI-BLUEPRINT-CARD). Deleting acts on the library immediately and leaves the
|
||||||
|
// dialog open (REQ-UI-BLUEPRINT-DELETE). Escape and the close button dismiss it with
|
||||||
|
// no other effect.
|
||||||
|
class BlueprintSelectionDialog : public QDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Neither the library nor the icon cache is owned; both outlive the dialog.
|
||||||
|
BlueprintSelectionDialog(BlueprintLibrary* library, ItemIconCache* itemIcons,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
std::optional<int> getChosenIndex() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rebuildGrid();
|
||||||
|
void onCardClicked(int index);
|
||||||
|
void onDeleteClicked(int index);
|
||||||
|
|
||||||
|
BlueprintLibrary* m_library;
|
||||||
|
ItemIconCache* m_itemIcons;
|
||||||
|
QWidget* m_gridContainer;
|
||||||
|
QGridLayout* m_grid;
|
||||||
|
std::optional<int> m_chosenIndex; // nullopt = dismissed without picking
|
||||||
|
};
|
||||||
420
src/ui/BuildButtonBar.cpp
Normal file
420
src/ui/BuildButtonBar.cpp
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
#include "BuildButtonBar.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFontMetrics>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QSignalMapper>
|
||||||
|
#include <QSize>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "BuildingIconCache.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "BuildingTypeSelectedEvent.h"
|
||||||
|
#include "DeconstructModeToggleRequestedEvent.h"
|
||||||
|
#include "DisplayName.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
#include "ExitBuilderModeRequestedEvent.h"
|
||||||
|
#include "IconCaption.h"
|
||||||
|
#include "InputMapper.h"
|
||||||
|
#include "ItemIconCache.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Size the SVG chips are drawn at on a button face, in device-independent pixels.
|
||||||
|
const QSize kIconSize(32, 32);
|
||||||
|
|
||||||
|
// Minimum width of a button face. The building-type buttons all come out at this
|
||||||
|
// width, so the row is uniform; a face whose caption is wider than this — the
|
||||||
|
// Deconstruct button's name — grows to fit rather than clipping
|
||||||
|
// (REQ-UI-DECONSTRUCT-BUTTON).
|
||||||
|
const int kFaceMinWidthPx = 48;
|
||||||
|
|
||||||
|
// Gap between the last building button and the Deconstruct button, which toggles
|
||||||
|
// a mode rather than selecting a building type (REQ-UI-DECONSTRUCT-BUTTON).
|
||||||
|
const int kDeconstructGapPx = 16;
|
||||||
|
|
||||||
|
// Distance from the bar to the bottom edge of the game world view
|
||||||
|
// (REQ-UI-BUILD-BAR).
|
||||||
|
const int kBottomMarginPx = 8;
|
||||||
|
|
||||||
|
// Gap between the chip icon and the cost line on a button face.
|
||||||
|
const int kFaceGapPx = 2;
|
||||||
|
|
||||||
|
// Normal and grey-background chip pixmaps for one icon name. Empty pixmaps if the
|
||||||
|
// file cannot be read; the caller then falls back to a name caption
|
||||||
|
// (REQ-UI-BUILD-ICON).
|
||||||
|
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
|
||||||
|
ChipPixmaps loadChipPixmaps(BuildingIconCache& icons, const std::string& iconName)
|
||||||
|
{
|
||||||
|
ChipPixmaps result;
|
||||||
|
result.normal = icons.getChip(iconName, kIconSize.width());
|
||||||
|
result.grey = icons.getGreyChip(iconName, kIconSize.width());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One button face: the hotkey badge in the top-left corner, the chip icon centered
|
||||||
|
// below it, and the caption — the cost, or the Deconstruct name — centered at the
|
||||||
|
// bottom (REQ-UI-BUILD-COST). The three are composed into a single pixmap because
|
||||||
|
// a QPushButton holds only one icon.
|
||||||
|
QPixmap composeButtonFace(const QString& hotkeyLabel, const QPixmap& chip,
|
||||||
|
const QPixmap& caption, const QFont& badgeFont,
|
||||||
|
const QColor& badgeColor)
|
||||||
|
{
|
||||||
|
const QSize chipSize = getLogicalSize(chip);
|
||||||
|
const QSize captionSize = getLogicalSize(caption);
|
||||||
|
// The badge row is kept even for a building type without a hotkey, so buttons
|
||||||
|
// stay the same height and the row reads as one strip (REQ-UI-BUILD-COST).
|
||||||
|
const int badgeHeight = QFontMetrics(badgeFont).height();
|
||||||
|
const int width = qMax(kFaceMinWidthPx, qMax(chipSize.width(), captionSize.width()));
|
||||||
|
const int height = badgeHeight + chipSize.height() + kFaceGapPx + captionSize.height();
|
||||||
|
|
||||||
|
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
|
||||||
|
QPixmap face(static_cast<int>(width * dpr), static_cast<int>(height * dpr));
|
||||||
|
face.setDevicePixelRatio(dpr);
|
||||||
|
face.fill(Qt::transparent);
|
||||||
|
|
||||||
|
QPainter painter(&face);
|
||||||
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||||
|
if (!hotkeyLabel.isEmpty())
|
||||||
|
{
|
||||||
|
painter.setFont(badgeFont);
|
||||||
|
painter.setPen(badgeColor);
|
||||||
|
painter.drawText(QRect(0, 0, width, badgeHeight),
|
||||||
|
Qt::AlignLeft | Qt::AlignVCenter, hotkeyLabel);
|
||||||
|
}
|
||||||
|
painter.drawPixmap((width - chipSize.width()) / 2, badgeHeight, chip);
|
||||||
|
painter.drawPixmap((width - captionSize.width()) / 2,
|
||||||
|
badgeHeight + chipSize.height() + kFaceGapPx, caption);
|
||||||
|
return face;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A composed button face and the size to show it at; the button needs both, and
|
||||||
|
// only the composer knows the size it arrived at.
|
||||||
|
struct ButtonFace { QIcon icon; QSize size; };
|
||||||
|
|
||||||
|
// The two-mode face of one build button. The modes differ only in the chip variant
|
||||||
|
// and the text color, so a disabled (unaffordable) button greys itself when Qt
|
||||||
|
// swaps the pixmap, with no extra work in updateAffordability()
|
||||||
|
// (REQ-UI-BUILD-DISABLED).
|
||||||
|
ButtonFace buildButtonFace(const ChipPixmaps& chip, const QString& hotkeyLabel,
|
||||||
|
const QString& name, const QString& captionText,
|
||||||
|
const QPixmap& blockIcon, const QFont& font,
|
||||||
|
const QPalette& palette)
|
||||||
|
{
|
||||||
|
// Full button size and bold: at a smaller size the badge was hard to read and
|
||||||
|
// its arrow glyph illegible. It stays dimmed in both modes instead, so it
|
||||||
|
// reads as a reminder without competing with the cost.
|
||||||
|
QFont badgeFont = font;
|
||||||
|
badgeFont.setBold(true);
|
||||||
|
const QColor badgeColor = palette.color(QPalette::Disabled, QPalette::ButtonText);
|
||||||
|
|
||||||
|
ButtonFace result;
|
||||||
|
for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled })
|
||||||
|
{
|
||||||
|
const bool enabled = (mode == QIcon::Normal);
|
||||||
|
const QColor textColor = palette.color(
|
||||||
|
enabled ? QPalette::Active : QPalette::Disabled, QPalette::ButtonText);
|
||||||
|
const QPixmap& chipPixmap = enabled ? chip.normal : chip.grey;
|
||||||
|
// A missing "<id>.svg" puts the building name where the chip would be, so
|
||||||
|
// the button stays identifiable in an icon-only bar (REQ-UI-BUILD-ICON).
|
||||||
|
const QPixmap middle = chipPixmap.isNull()
|
||||||
|
? renderCaptionWithIcon(name, QPixmap(), font, textColor)
|
||||||
|
: chipPixmap;
|
||||||
|
const QPixmap caption =
|
||||||
|
renderCaptionWithIcon(captionText, blockIcon, font, textColor);
|
||||||
|
const QPixmap face =
|
||||||
|
composeButtonFace(hotkeyLabel, middle, caption, badgeFont, badgeColor);
|
||||||
|
result.icon.addPixmap(face, mode);
|
||||||
|
// Both modes compose to the same size; keeping the larger is only a guard
|
||||||
|
// against a fallback name caption widening one of them.
|
||||||
|
result.size = result.size.expandedTo(getLogicalSize(face));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
BuildButtonBar::BuildButtonBar(Simulation* sim, const GameConfig* config,
|
||||||
|
BuildingIconCache* buildingIcons,
|
||||||
|
ItemIconCache* itemIcons, QWidget* parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
, m_sim(sim)
|
||||||
|
, m_config(config)
|
||||||
|
, m_buildingIcons(buildingIcons)
|
||||||
|
, m_itemIcons(itemIcons)
|
||||||
|
{
|
||||||
|
// 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
|
||||||
|
// (REQ-UI-BUILD-BAR). Palette colors keep it consistent with the buttons it holds
|
||||||
|
// and with the selection panel; this is widget chrome, not world rendering, so it is
|
||||||
|
// deliberately not a visuals.toml color.
|
||||||
|
setAttribute(Qt::WA_StyledBackground, true);
|
||||||
|
setStyleSheet(QStringLiteral(
|
||||||
|
"BuildButtonBar { background-color: palette(window);"
|
||||||
|
" border: 1px solid palette(mid); border-radius: 4px; }"));
|
||||||
|
|
||||||
|
QHBoxLayout* layout = new QHBoxLayout(this);
|
||||||
|
layout->setSpacing(4);
|
||||||
|
layout->setContentsMargins(6, 4, 6, 4);
|
||||||
|
|
||||||
|
QSignalMapper* mapper = new QSignalMapper(this);
|
||||||
|
|
||||||
|
// Block icon shown to the right of each button's cost (REQ-UI-BUILD-COST); null
|
||||||
|
// when no building_block icon exists, in which case the cost is the bare number.
|
||||||
|
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
|
||||||
|
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
|
||||||
|
: QPixmap();
|
||||||
|
|
||||||
|
for (const BuildingDef& def : config->buildings.buildings)
|
||||||
|
{
|
||||||
|
if (!def.playerPlaceable)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
|
||||||
|
// reached through the unified tunnel build mode, not its own button
|
||||||
|
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-BAR). Both stay player-placeable so
|
||||||
|
// blueprints and cost totals still account for exits.
|
||||||
|
if (def.type == BuildingType::TunnelExit)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
m_types.push_back(def.type);
|
||||||
|
m_costs[def.type] = def.cost;
|
||||||
|
|
||||||
|
const QString name = (def.type == BuildingType::TunnelEntry)
|
||||||
|
? tr("Tunnel")
|
||||||
|
: QString::fromStdString(toDisplayName(def.id));
|
||||||
|
|
||||||
|
// Icon file name matches the building id (REQ-UI-BUILD-ICON); Tunnel Entry's
|
||||||
|
// "tunnel_entry.svg" serves the shared Tunnel button.
|
||||||
|
const ButtonFace face = buildButtonFace(
|
||||||
|
loadChipPixmaps(*m_buildingIcons, def.id),
|
||||||
|
InputMapper::getBuildHotkeyLabel(def.type), name,
|
||||||
|
QString::number(def.cost), blockIcon, font(), palette());
|
||||||
|
|
||||||
|
QPushButton* btn = new QPushButton(this);
|
||||||
|
btn->setIcon(face.icon);
|
||||||
|
btn->setIconSize(face.size);
|
||||||
|
btn->setCheckable(true);
|
||||||
|
// The button face carries no name (REQ-UI-BUILD-COST), so the tooltip always
|
||||||
|
// leads with it and adds the config description when there is one
|
||||||
|
// (REQ-UI-BUILD-TOOLTIP).
|
||||||
|
btn->setToolTip(def.tooltip
|
||||||
|
? QStringLiteral("%1\n%2").arg(name, QString::fromStdString(*def.tooltip))
|
||||||
|
: name);
|
||||||
|
layout->addWidget(btn);
|
||||||
|
|
||||||
|
const int idx = static_cast<int>(m_buttons.size());
|
||||||
|
m_buttons.push_back(btn);
|
||||||
|
mapper->setMapping(btn, idx);
|
||||||
|
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
|
||||||
|
}
|
||||||
|
connect(mapper, qOverload<int>(&QSignalMapper::mapped), this, &BuildButtonBar::onBuildButton);
|
||||||
|
|
||||||
|
// Set apart from the building-type buttons by a gap, because it toggles a mode
|
||||||
|
// rather than selecting a building type (REQ-UI-DECONSTRUCT-BUTTON). A fixed
|
||||||
|
// spacer rather than a stretch: the bar is sized to its contents, so there is no
|
||||||
|
// right edge for a stretch to push against.
|
||||||
|
layout->addSpacing(kDeconstructGapPx);
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
const ButtonFace deconstructFace = buildButtonFace(
|
||||||
|
loadChipPixmaps(*m_buildingIcons, "deconstruct"),
|
||||||
|
QStringLiteral("Q"), tr("Deconstruct"), tr("Deconstruct"), QPixmap(),
|
||||||
|
font(), palette());
|
||||||
|
|
||||||
|
m_deconstructButton = new QPushButton(this);
|
||||||
|
m_deconstructButton->setCheckable(true);
|
||||||
|
m_deconstructButton->setIcon(deconstructFace.icon);
|
||||||
|
m_deconstructButton->setIconSize(deconstructFace.size);
|
||||||
|
// Refund tooltip composed from world.refund_percentage (REQ-UI-DECONSTRUCT-BUTTON,
|
||||||
|
// REQ-BLD-DECONSTRUCT). A finished building refunds the configured percentage; a
|
||||||
|
// construction site removed before it is built is refunded in full. When the
|
||||||
|
// percentage is 100% both cases coincide, so the tooltip is simplified to one case.
|
||||||
|
const int refundPercentage = m_config->world.refundPercentage;
|
||||||
|
const QString deconstructTooltip = (refundPercentage >= 100)
|
||||||
|
? tr("Deconstruct buildings. Refunds %1% of the building block cost.")
|
||||||
|
.arg(refundPercentage)
|
||||||
|
: tr("Deconstruct buildings. A finished building refunds %1% of its building "
|
||||||
|
"block cost once removed; a construction site removed before it is built "
|
||||||
|
"is refunded in full.")
|
||||||
|
.arg(refundPercentage);
|
||||||
|
m_deconstructButton->setToolTip(deconstructTooltip);
|
||||||
|
layout->addWidget(m_deconstructButton);
|
||||||
|
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<DeconstructModeToggleRequestedEvent>());
|
||||||
|
});
|
||||||
|
|
||||||
|
updateVisibility();
|
||||||
|
registerForEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildButtonBar::~BuildButtonBar()
|
||||||
|
{
|
||||||
|
unregisterForEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::anchorTo(const QRect& worldViewRect)
|
||||||
|
{
|
||||||
|
m_viewRect = worldViewRect;
|
||||||
|
recenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
int BuildButtonBar::getStripHeightPx() const
|
||||||
|
{
|
||||||
|
return height() + kBottomMarginPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::clearActiveButton()
|
||||||
|
{
|
||||||
|
if (m_activeIndex)
|
||||||
|
{
|
||||||
|
m_buttons[*m_activeIndex]->setChecked(false);
|
||||||
|
}
|
||||||
|
m_activeIndex.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::updateAffordability()
|
||||||
|
{
|
||||||
|
const int buildingBlocks = m_sim->getBuildingBlocksStock();
|
||||||
|
|
||||||
|
// If the currently selected tool can no longer be afforded, exit builder mode
|
||||||
|
// before recomputing button states so it does not stay selected. Clearing the
|
||||||
|
// active index first lets the loop below disable the now-unaffordable button.
|
||||||
|
if (m_activeIndex)
|
||||||
|
{
|
||||||
|
const BuildingType activeType = m_types[*m_activeIndex];
|
||||||
|
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
|
||||||
|
const int cost = (it != m_costs.end()) ? it->second : 0;
|
||||||
|
if (buildingBlocks < cost)
|
||||||
|
{
|
||||||
|
clearActiveButton();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<ExitBuilderModeRequestedEvent>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < m_buttons.size(); ++i)
|
||||||
|
{
|
||||||
|
const BuildingType type = m_types[i];
|
||||||
|
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
|
||||||
|
const int cost = (it != m_costs.end()) ? it->second : 0;
|
||||||
|
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::updateVisibility()
|
||||||
|
{
|
||||||
|
// A locked building type's button is hidden until its unlock group is awarded
|
||||||
|
// (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay
|
||||||
|
// stable; they simply appear once the type is unlocked.
|
||||||
|
for (std::size_t i = 0; i < m_buttons.size(); ++i)
|
||||||
|
{
|
||||||
|
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
|
||||||
|
}
|
||||||
|
// A hidden button leaves the row, so the bar has to take up its new width and
|
||||||
|
// re-center on it (REQ-UI-BUILD-BAR).
|
||||||
|
recenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::recenter()
|
||||||
|
{
|
||||||
|
if (m_viewRect.isNull())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The layout drops hidden buttons from its size hint, but only once it has been
|
||||||
|
// re-run: updateVisibility() calls this straight after setVisible(), before Qt
|
||||||
|
// would get around to it on its own.
|
||||||
|
layout()->activate();
|
||||||
|
|
||||||
|
const QSize barSize = sizeHint();
|
||||||
|
// Centered, except that a bar wider than the view stays flush with its left edge
|
||||||
|
// rather than hanging off both sides.
|
||||||
|
const int x = qMax(m_viewRect.left(),
|
||||||
|
m_viewRect.left() + (m_viewRect.width() - barSize.width()) / 2);
|
||||||
|
const int y = m_viewRect.bottom() - kBottomMarginPx - barSize.height() + 1;
|
||||||
|
setGeometry(QRect(QPoint(x, y), barSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::onBuildButton(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::size_t idx = static_cast<std::size_t>(index);
|
||||||
|
|
||||||
|
if (m_activeIndex == idx)
|
||||||
|
{
|
||||||
|
clearActiveButton();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<ExitBuilderModeRequestedEvent>());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_activeIndex)
|
||||||
|
{
|
||||||
|
m_buttons[*m_activeIndex]->setChecked(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_activeIndex = idx;
|
||||||
|
m_buttons[idx]->setChecked(true);
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
clearActiveButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
updateAffordability();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
updateVisibility();
|
||||||
|
updateAffordability();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event)
|
||||||
|
{
|
||||||
|
m_deconstructButton->setChecked(event->active);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildButtonBar::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event)
|
||||||
|
{
|
||||||
|
for (std::size_t i = 0; i < m_types.size(); ++i)
|
||||||
|
{
|
||||||
|
if (m_types[i] == event->type)
|
||||||
|
{
|
||||||
|
// Equivalent to clicking the build button: a disabled (unaffordable) or
|
||||||
|
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
|
||||||
|
// hotkey is likewise inert.
|
||||||
|
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
|
||||||
|
{
|
||||||
|
onBuildButton(static_cast<int>(i));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/ui/BuildButtonBar.h
Normal file
95
src/ui/BuildButtonBar.h
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QRect>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "BuilderModeExitedEvent.h"
|
||||||
|
#include "BuildHotkeyPressedEvent.h"
|
||||||
|
#include "BuildingBlocksChangedEvent.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "DeconstructModeChangedEvent.h"
|
||||||
|
#include "EventHandler.h"
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "UnlockedBuildingsChangedEvent.h"
|
||||||
|
|
||||||
|
class QPushButton;
|
||||||
|
class Simulation;
|
||||||
|
class BuildingIconCache;
|
||||||
|
class ItemIconCache;
|
||||||
|
|
||||||
|
// The build menu: one horizontal row of build buttons floating over the game world
|
||||||
|
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons; its owner hands it the
|
||||||
|
// world view's rect through anchorTo() and it centers itself along that rect's
|
||||||
|
// bottom edge.
|
||||||
|
class BuildButtonBar : public QWidget,
|
||||||
|
public CombinedEventHandler<BuilderModeExitedEvent,
|
||||||
|
DeconstructModeChangedEvent,
|
||||||
|
BuildHotkeyPressedEvent,
|
||||||
|
BuildingBlocksChangedEvent,
|
||||||
|
UnlockedBuildingsChangedEvent>
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// buildingIcons supplies each button's chip icon (REQ-UI-BUILD-ICON) and itemIcons
|
||||||
|
// the building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Both are
|
||||||
|
// window-wide caches; neither is owned, and both must outlive this widget.
|
||||||
|
BuildButtonBar(Simulation* sim, const GameConfig* config,
|
||||||
|
BuildingIconCache* buildingIcons, ItemIconCache* itemIcons,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
|
~BuildButtonBar() override;
|
||||||
|
|
||||||
|
// Centers the bar along the bottom edge of the game world view's rect, given in
|
||||||
|
// the bar's parent coordinates (REQ-UI-BUILD-BAR). The rect is remembered, so a
|
||||||
|
// re-center later driven by an unlock needs no second call from the owner.
|
||||||
|
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();
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Re-evaluates which build buttons are enabled from the current building block
|
||||||
|
// stock (read from the simulation). If the currently selected tool can no longer
|
||||||
|
// be afforded, it exits builder mode so the button does not stay selected.
|
||||||
|
void updateAffordability();
|
||||||
|
|
||||||
|
// Re-evaluates which build buttons are visible from the current per-building
|
||||||
|
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
|
||||||
|
void updateVisibility();
|
||||||
|
|
||||||
|
// Shrinks the bar to its currently shown buttons and re-centers it in the
|
||||||
|
// anchored rect (REQ-UI-BUILD-BAR). Does nothing until anchorTo() supplied that
|
||||||
|
// rect, so the construction-time call is harmless.
|
||||||
|
void recenter();
|
||||||
|
|
||||||
|
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> event) override;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void onBuildButton(int index);
|
||||||
|
|
||||||
|
private:
|
||||||
|
Simulation* m_sim;
|
||||||
|
const GameConfig* m_config;
|
||||||
|
BuildingIconCache* m_buildingIcons; // Not owned; lives in MainWindow.
|
||||||
|
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
|
||||||
|
std::vector<BuildingType> m_types;
|
||||||
|
std::vector<QPushButton*> m_buttons;
|
||||||
|
std::map<BuildingType, int> m_costs;
|
||||||
|
std::optional<std::size_t> m_activeIndex;
|
||||||
|
QPushButton* m_deconstructButton;
|
||||||
|
QRect m_viewRect;
|
||||||
|
};
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
#include "BuildButtonGrid.h"
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QColor>
|
|
||||||
#include <QFile>
|
|
||||||
#include <QFontMetrics>
|
|
||||||
#include <QGridLayout>
|
|
||||||
#include <QIcon>
|
|
||||||
#include <QPainter>
|
|
||||||
#include <QPaintEvent>
|
|
||||||
#include <QPalette>
|
|
||||||
#include <QPixmap>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QRect>
|
|
||||||
#include <QRegularExpression>
|
|
||||||
#include <QSignalMapper>
|
|
||||||
#include <QSize>
|
|
||||||
#include <QString>
|
|
||||||
#include <QSvgRenderer>
|
|
||||||
|
|
||||||
#include "BuildingType.h"
|
|
||||||
#include "BuildingTypeSelectedEvent.h"
|
|
||||||
#include "DeconstructModeToggleRequestedEvent.h"
|
|
||||||
#include "DisplayName.h"
|
|
||||||
#include "EventManager.h"
|
|
||||||
#include "ExitBuilderModeRequestedEvent.h"
|
|
||||||
#include "ItemIconCache.h"
|
|
||||||
#include "Simulation.h"
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
// Pixel size the SVG chips are rasterized at; downscaled to the button icon size.
|
|
||||||
const int kIconRenderSize = 64;
|
|
||||||
const QSize kIconSize(28, 28);
|
|
||||||
|
|
||||||
QPixmap renderChip(const QByteArray& svg)
|
|
||||||
{
|
|
||||||
QSvgRenderer renderer(svg);
|
|
||||||
QPixmap pixmap(kIconRenderSize, kIconRenderSize);
|
|
||||||
pixmap.fill(Qt::transparent);
|
|
||||||
QPainter painter(&pixmap);
|
|
||||||
renderer.render(&painter);
|
|
||||||
return pixmap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builds a build-button icon from a "<id>.svg" chip file. The returned QIcon also
|
|
||||||
// carries a Disabled-mode pixmap whose chip background is recolored grey, so an
|
|
||||||
// unaffordable (disabled) button shows the grey variant automatically
|
|
||||||
// (REQ-UI-BUILD-DISABLED) without any extra work in updateAffordability().
|
|
||||||
QIcon loadBuildingIcon(const QString& path)
|
|
||||||
{
|
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::ReadOnly))
|
|
||||||
{
|
|
||||||
return QIcon();
|
|
||||||
}
|
|
||||||
const QByteArray svg = file.readAll();
|
|
||||||
|
|
||||||
QIcon icon;
|
|
||||||
icon.addPixmap(renderChip(svg), QIcon::Normal);
|
|
||||||
|
|
||||||
// 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\""));
|
|
||||||
}
|
|
||||||
icon.addPixmap(renderChip(greyed.toUtf8()), QIcon::Disabled);
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal and grey-background chip pixmaps for a "<id>.svg" file, using the same
|
|
||||||
// recolor rule as loadBuildingIcon. Empty pixmaps if the file cannot be read.
|
|
||||||
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
|
|
||||||
ChipPixmaps loadChipPixmaps(const QString& path)
|
|
||||||
{
|
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::ReadOnly)) { return {}; }
|
|
||||||
const QByteArray svg = file.readAll();
|
|
||||||
|
|
||||||
ChipPixmaps result;
|
|
||||||
result.normal = renderChip(svg);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A build button that paints its own face — chip icon at the left, the building
|
|
||||||
// name above the cost, and the building_block item icon after the cost number in
|
|
||||||
// place of the "Blocks" word (REQ-UI-BUILD-COST, REQ-UI-BUILD-ICON). Custom paint
|
|
||||||
// (rather than the native icon+text) is needed because a QPushButton holds only
|
|
||||||
// one icon; this stays adaptive to the button width and greys itself when the
|
|
||||||
// button is disabled/unaffordable (REQ-UI-BUILD-DISABLED).
|
|
||||||
class BuildButton : public QPushButton
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
BuildButton(const ChipPixmaps& chip, const QString& name,
|
|
||||||
const QString& costText, const QPixmap& blockIcon, QWidget* parent)
|
|
||||||
: QPushButton(parent)
|
|
||||||
, m_chip(chip)
|
|
||||||
, m_name(name)
|
|
||||||
, m_costText(costText)
|
|
||||||
, m_blockIcon(blockIcon)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void paintEvent(QPaintEvent* event) override
|
|
||||||
{
|
|
||||||
QPushButton::paintEvent(event); // frame, checked/hover state
|
|
||||||
|
|
||||||
QPainter painter(this);
|
|
||||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
|
||||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
|
||||||
|
|
||||||
const bool on = isEnabled();
|
|
||||||
const QRect area = rect().adjusted(6, 4, -6, -4);
|
|
||||||
const int chipSize = kIconSize.width();
|
|
||||||
const QPixmap& chip = on ? m_chip.normal : m_chip.grey;
|
|
||||||
if (!chip.isNull())
|
|
||||||
{
|
|
||||||
painter.drawPixmap(
|
|
||||||
QRect(area.x(), area.y() + (area.height() - chipSize) / 2,
|
|
||||||
chipSize, chipSize), chip);
|
|
||||||
}
|
|
||||||
|
|
||||||
const QRect textArea(area.x() + chipSize + 6, area.y(),
|
|
||||||
area.width() - chipSize - 6, area.height());
|
|
||||||
const QFontMetrics metrics(font());
|
|
||||||
const int lineHeight = metrics.height();
|
|
||||||
painter.setFont(font());
|
|
||||||
painter.setPen(palette().color(on ? QPalette::Active : QPalette::Disabled,
|
|
||||||
QPalette::ButtonText));
|
|
||||||
|
|
||||||
// Name fills everything above the bottom cost line (word-wrapped).
|
|
||||||
painter.drawText(
|
|
||||||
QRect(textArea.x(), textArea.y(),
|
|
||||||
textArea.width(), textArea.height() - lineHeight),
|
|
||||||
Qt::AlignLeft | Qt::AlignVCenter | Qt::TextWordWrap, m_name);
|
|
||||||
|
|
||||||
// Cost line: "<n>" then the block icon (or "<n> Blocks" when no icon).
|
|
||||||
const int costY = textArea.bottom() - lineHeight + 1;
|
|
||||||
if (m_blockIcon.isNull())
|
|
||||||
{
|
|
||||||
painter.drawText(
|
|
||||||
QRect(textArea.x(), costY, textArea.width(), lineHeight),
|
|
||||||
Qt::AlignLeft | Qt::AlignVCenter,
|
|
||||||
QObject::tr("%1 Blocks").arg(m_costText));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const int costWidth = metrics.horizontalAdvance(m_costText);
|
|
||||||
painter.drawText(QRect(textArea.x(), costY, costWidth, lineHeight),
|
|
||||||
Qt::AlignLeft | Qt::AlignVCenter, m_costText);
|
|
||||||
const qreal iconDpr = m_blockIcon.devicePixelRatio();
|
|
||||||
const int iconW = static_cast<int>(m_blockIcon.width() / iconDpr);
|
|
||||||
const int iconH = static_cast<int>(m_blockIcon.height() / iconDpr);
|
|
||||||
if (!on) { painter.setOpacity(0.45); }
|
|
||||||
painter.drawPixmap(
|
|
||||||
QRect(textArea.x() + costWidth + 4, costY + (lineHeight - iconH) / 2,
|
|
||||||
iconW, iconH), m_blockIcon);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
ChipPixmaps m_chip;
|
|
||||||
QString m_name;
|
|
||||||
QString m_costText;
|
|
||||||
QPixmap m_blockIcon;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
|
|
||||||
const std::string& iconDir,
|
|
||||||
ItemIconCache* itemIcons, QWidget* parent)
|
|
||||||
: QWidget(parent)
|
|
||||||
, m_sim(sim)
|
|
||||||
, m_config(config)
|
|
||||||
, m_iconDir(iconDir)
|
|
||||||
, m_itemIcons(itemIcons)
|
|
||||||
{
|
|
||||||
QGridLayout* layout = new QGridLayout(this);
|
|
||||||
layout->setSpacing(4);
|
|
||||||
layout->setContentsMargins(4, 4, 4, 4);
|
|
||||||
|
|
||||||
QSignalMapper* mapper = new QSignalMapper(this);
|
|
||||||
int col = 0;
|
|
||||||
int row = 0;
|
|
||||||
const int kCols = 3;
|
|
||||||
|
|
||||||
// Block icon shown in each button's cost line (REQ-UI-BUILD-COST); null when no
|
|
||||||
// building_block icon exists, in which case buttons fall back to text costs.
|
|
||||||
const bool hasBlockIcon = m_itemIcons->hasIcon("building_block");
|
|
||||||
const QPixmap blockIcon = hasBlockIcon
|
|
||||||
? m_itemIcons->getPixmap("building_block", QFontMetrics(font()).height())
|
|
||||||
: QPixmap();
|
|
||||||
|
|
||||||
for (const BuildingDef& def : config->buildings.buildings)
|
|
||||||
{
|
|
||||||
if (!def.playerPlaceable)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
|
|
||||||
// reached through the unified tunnel build mode, not its own button
|
|
||||||
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-GRID). Both stay player-placeable so
|
|
||||||
// blueprints and cost totals still account for exits.
|
|
||||||
if (def.type == BuildingType::TunnelExit)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
m_types.push_back(def.type);
|
|
||||||
m_costs[def.type] = def.cost;
|
|
||||||
|
|
||||||
const QString name = (def.type == BuildingType::TunnelEntry)
|
|
||||||
? tr("Tunnel")
|
|
||||||
: QString::fromStdString(toDisplayName(def.id));
|
|
||||||
// Icon file name matches the building id (REQ-UI-BUILD-GRID); Tunnel Entry's
|
|
||||||
// "tunnel_entry.svg" serves the shared Tunnel button.
|
|
||||||
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
|
|
||||||
+ QString::fromStdString(def.id) + ".svg";
|
|
||||||
|
|
||||||
QPushButton* btn = nullptr;
|
|
||||||
if (hasBlockIcon)
|
|
||||||
{
|
|
||||||
// Custom-painted button showing the cost with the block icon in place of
|
|
||||||
// the "Blocks" word (REQ-UI-BUILD-COST).
|
|
||||||
btn = new BuildButton(loadChipPixmaps(iconPath), name,
|
|
||||||
QString::number(def.cost), blockIcon, this);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fallback: native chip icon + text cost when no block icon exists.
|
|
||||||
btn = new QPushButton(name + "\n" + tr("%1 Blocks").arg(def.cost), this);
|
|
||||||
btn->setIcon(loadBuildingIcon(iconPath));
|
|
||||||
btn->setIconSize(kIconSize);
|
|
||||||
}
|
|
||||||
btn->setCheckable(true);
|
|
||||||
btn->setFixedHeight(48);
|
|
||||||
if (def.tooltip)
|
|
||||||
{
|
|
||||||
btn->setToolTip(QString::fromStdString(*def.tooltip));
|
|
||||||
}
|
|
||||||
layout->addWidget(btn, row, col);
|
|
||||||
|
|
||||||
const int idx = static_cast<int>(m_buttons.size());
|
|
||||||
m_buttons.push_back(btn);
|
|
||||||
mapper->setMapping(btn, idx);
|
|
||||||
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
|
|
||||||
|
|
||||||
++col;
|
|
||||||
if (col >= kCols)
|
|
||||||
{
|
|
||||||
col = 0;
|
|
||||||
++row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connect(mapper, qOverload<int>(&QSignalMapper::mapped), this, &BuildButtonGrid::onBuildButton);
|
|
||||||
|
|
||||||
m_deconstructButton = new QPushButton(tr("Deconstruct"), this);
|
|
||||||
m_deconstructButton->setCheckable(true);
|
|
||||||
m_deconstructButton->setFixedHeight(48);
|
|
||||||
m_deconstructButton->setIcon(loadBuildingIcon(
|
|
||||||
QString::fromStdString(m_iconDir) + "/deconstruct.svg"));
|
|
||||||
m_deconstructButton->setIconSize(kIconSize);
|
|
||||||
// Refund tooltip composed from world.refund_percentage (REQ-UI-DECONSTRUCT-BUTTON,
|
|
||||||
// REQ-BLD-DECONSTRUCT). A finished building refunds the configured percentage; a
|
|
||||||
// construction site removed before it is built is refunded in full. When the
|
|
||||||
// percentage is 100% both cases coincide, so the tooltip is simplified to one case.
|
|
||||||
const int refundPercentage = m_config->world.refundPercentage;
|
|
||||||
const QString deconstructTooltip = (refundPercentage >= 100)
|
|
||||||
? tr("Deconstruct buildings. Refunds %1% of the building block cost.")
|
|
||||||
.arg(refundPercentage)
|
|
||||||
: tr("Deconstruct buildings. A finished building refunds %1% of its building "
|
|
||||||
"block cost once removed; a construction site removed before it is built "
|
|
||||||
"is refunded in full.")
|
|
||||||
.arg(refundPercentage);
|
|
||||||
m_deconstructButton->setToolTip(deconstructTooltip);
|
|
||||||
layout->addWidget(m_deconstructButton, row, col);
|
|
||||||
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<DeconstructModeToggleRequestedEvent>());
|
|
||||||
});
|
|
||||||
|
|
||||||
updateVisibility();
|
|
||||||
registerForEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
BuildButtonGrid::~BuildButtonGrid()
|
|
||||||
{
|
|
||||||
unregisterForEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::updateAffordability()
|
|
||||||
{
|
|
||||||
const int buildingBlocks = m_sim->getBuildingBlocksStock();
|
|
||||||
|
|
||||||
// If the currently selected tool can no longer be afforded, exit builder mode
|
|
||||||
// before recomputing button states so it does not stay selected. Clearing the
|
|
||||||
// active index first lets the loop below disable the now-unaffordable button.
|
|
||||||
if (m_activeIndex)
|
|
||||||
{
|
|
||||||
const BuildingType activeType = m_types[*m_activeIndex];
|
|
||||||
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
|
|
||||||
const int cost = (it != m_costs.end()) ? it->second : 0;
|
|
||||||
if (buildingBlocks < cost)
|
|
||||||
{
|
|
||||||
clearActiveButton();
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<ExitBuilderModeRequestedEvent>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (std::size_t i = 0; i < m_buttons.size(); ++i)
|
|
||||||
{
|
|
||||||
const BuildingType type = m_types[i];
|
|
||||||
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
|
|
||||||
const int cost = (it != m_costs.end()) ? it->second : 0;
|
|
||||||
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::updateVisibility()
|
|
||||||
{
|
|
||||||
// A locked building type's button is hidden until its unlock group is awarded
|
|
||||||
// (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay
|
|
||||||
// stable; they simply appear once the type is unlocked.
|
|
||||||
for (std::size_t i = 0; i < m_buttons.size(); ++i)
|
|
||||||
{
|
|
||||||
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::clearActiveButton()
|
|
||||||
{
|
|
||||||
if (m_activeIndex)
|
|
||||||
{
|
|
||||||
m_buttons[*m_activeIndex]->setChecked(false);
|
|
||||||
}
|
|
||||||
m_activeIndex.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::onBuildButton(int index)
|
|
||||||
{
|
|
||||||
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const std::size_t idx = static_cast<std::size_t>(index);
|
|
||||||
|
|
||||||
if (m_activeIndex == idx)
|
|
||||||
{
|
|
||||||
clearActiveButton();
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<ExitBuilderModeRequestedEvent>());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_activeIndex)
|
|
||||||
{
|
|
||||||
m_buttons[*m_activeIndex]->setChecked(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
m_activeIndex = idx;
|
|
||||||
m_buttons[idx]->setChecked(true);
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
clearActiveButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
updateAffordability();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
updateVisibility();
|
|
||||||
updateAffordability();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event)
|
|
||||||
{
|
|
||||||
m_deconstructButton->setChecked(event->active);
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event)
|
|
||||||
{
|
|
||||||
for (std::size_t i = 0; i < m_types.size(); ++i)
|
|
||||||
{
|
|
||||||
if (m_types[i] == event->type)
|
|
||||||
{
|
|
||||||
// Equivalent to clicking the build button: a disabled (unaffordable) or
|
|
||||||
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
|
|
||||||
// hotkey is likewise inert.
|
|
||||||
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
|
|
||||||
{
|
|
||||||
onBuildButton(static_cast<int>(i));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <map>
|
|
||||||
#include <memory>
|
|
||||||
#include <optional>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <QWidget>
|
|
||||||
|
|
||||||
#include "BuilderModeExitedEvent.h"
|
|
||||||
#include "BuildHotkeyPressedEvent.h"
|
|
||||||
#include "BuildingBlocksChangedEvent.h"
|
|
||||||
#include "BuildingType.h"
|
|
||||||
#include "DeconstructModeChangedEvent.h"
|
|
||||||
#include "EventHandler.h"
|
|
||||||
#include "GameConfig.h"
|
|
||||||
#include "UnlockedBuildingsChangedEvent.h"
|
|
||||||
|
|
||||||
class QPushButton;
|
|
||||||
class Simulation;
|
|
||||||
class ItemIconCache;
|
|
||||||
|
|
||||||
class BuildButtonGrid : public QWidget,
|
|
||||||
public CombinedEventHandler<BuilderModeExitedEvent,
|
|
||||||
DeconstructModeChangedEvent,
|
|
||||||
BuildHotkeyPressedEvent,
|
|
||||||
BuildingBlocksChangedEvent,
|
|
||||||
UnlockedBuildingsChangedEvent>
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
|
|
||||||
public:
|
|
||||||
// iconDir is the directory holding the per-building "<id>.svg" chip icons
|
|
||||||
// (REQ-UI-BUILD-GRID), read from disk at runtime like the config files.
|
|
||||||
// itemIcons is the window-wide per-item icon cache and supplies the
|
|
||||||
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Not
|
|
||||||
// owned; must outlive this widget.
|
|
||||||
BuildButtonGrid(Simulation* sim, const GameConfig* config,
|
|
||||||
const std::string& iconDir, ItemIconCache* itemIcons,
|
|
||||||
QWidget* parent = nullptr);
|
|
||||||
~BuildButtonGrid() override;
|
|
||||||
|
|
||||||
void clearActiveButton();
|
|
||||||
|
|
||||||
private:
|
|
||||||
// Re-evaluates which build buttons are enabled from the current building block
|
|
||||||
// stock (read from the simulation). If the currently selected tool can no longer
|
|
||||||
// be afforded, it exits builder mode so the button does not stay selected.
|
|
||||||
void updateAffordability();
|
|
||||||
|
|
||||||
// Re-evaluates which build buttons are visible from the current per-building
|
|
||||||
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
|
|
||||||
void updateVisibility();
|
|
||||||
|
|
||||||
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
|
||||||
void handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> event) override;
|
|
||||||
|
|
||||||
private slots:
|
|
||||||
void onBuildButton(int index);
|
|
||||||
|
|
||||||
private:
|
|
||||||
Simulation* m_sim;
|
|
||||||
const GameConfig* m_config;
|
|
||||||
std::string m_iconDir;
|
|
||||||
ItemIconCache* m_itemIcons; // Not owned; lives in MainWindow.
|
|
||||||
std::vector<BuildingType> m_types;
|
|
||||||
std::vector<QPushButton*> m_buttons;
|
|
||||||
std::map<BuildingType, int> m_costs;
|
|
||||||
std::optional<std::size_t> m_activeIndex;
|
|
||||||
QPushButton* m_deconstructButton;
|
|
||||||
};
|
|
||||||
120
src/ui/BuildingIconCache.cpp
Normal file
120
src/ui/BuildingIconCache.cpp
Normal 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;
|
||||||
|
}
|
||||||
55
src/ui/BuildingIconCache.h
Normal file
55
src/ui/BuildingIconCache.h
Normal 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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
@@ -6,11 +8,16 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.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}/ControlsPanel.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
|
||||||
@@ -18,21 +25,32 @@ 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
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/GameWorldView.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/InputMapper.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.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}/ControlsPanel.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ControlActionText.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
|
||||||
@@ -40,6 +58,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
|
||||||
)
|
)
|
||||||
|
|||||||
92
src/ui/ControlActionText.cpp
Normal file
92
src/ui/ControlActionText.cpp
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#include "ControlActionText.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QKeySequence>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// tr() for a file of free functions. Q_DECLARE_TR_FUNCTIONS expands with access
|
||||||
|
// specifiers, so it needs a class rather than a namespace; this struct exists only to
|
||||||
|
// carry it and give the strings a single lupdate context.
|
||||||
|
struct Strings
|
||||||
|
{
|
||||||
|
Q_DECLARE_TR_FUNCTIONS(ControlActionText)
|
||||||
|
};
|
||||||
|
|
||||||
|
QString getMouseBadge(MouseBinding binding)
|
||||||
|
{
|
||||||
|
switch (binding)
|
||||||
|
{
|
||||||
|
case MouseBinding::LeftClick: return Strings::tr("LMB");
|
||||||
|
case MouseBinding::LeftDrag: return Strings::tr("LMB drag");
|
||||||
|
case MouseBinding::CtrlLeftClick: return Strings::tr("Ctrl+LMB");
|
||||||
|
case MouseBinding::CtrlLeftDrag: return Strings::tr("Ctrl+LMB drag");
|
||||||
|
case MouseBinding::RightClick: return Strings::tr("RMB");
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString getControlBindingBadge(const ControlBinding& binding)
|
||||||
|
{
|
||||||
|
if (binding.isMouse) { return getMouseBadge(binding.mouse); }
|
||||||
|
|
||||||
|
// QKeySequence spells the modifiers and the key together and localizes them, which
|
||||||
|
// is what makes this stay correct once keys are rebindable: the chip is generated
|
||||||
|
// from the binding rather than typed next to it.
|
||||||
|
return QKeySequence(binding.key | static_cast<int>(binding.modifiers))
|
||||||
|
.toString(QKeySequence::NativeText);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString getControlActionLabel(ControlAction action, const ControlContext& context)
|
||||||
|
{
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case ControlAction::None: return QString();
|
||||||
|
case ControlAction::Move: return Strings::tr("Move");
|
||||||
|
case ControlAction::GameSpeed: return Strings::tr("Game speed");
|
||||||
|
case ControlAction::TogglePause: return Strings::tr("Toggle pause");
|
||||||
|
case ControlAction::PasteTemporary: return Strings::tr("Paste last");
|
||||||
|
case ControlAction::OpenBlueprints: return Strings::tr("Blueprints");
|
||||||
|
case ControlAction::OpenMenu: return Strings::tr("Menu");
|
||||||
|
// A click on empty space clears and a click on an object replaces; naming only the
|
||||||
|
// first would be a half-truth once something is selected.
|
||||||
|
case ControlAction::Select:
|
||||||
|
return context.selection == ControlSelection::None
|
||||||
|
? Strings::tr("Select")
|
||||||
|
: Strings::tr("Select / clear selection");
|
||||||
|
case ControlAction::SelectArea: return Strings::tr("Select area");
|
||||||
|
case ControlAction::AddToSelection: return Strings::tr("Add / remove from selection");
|
||||||
|
case ControlAction::AddAreaToSelection: return Strings::tr("Add area to selection");
|
||||||
|
case ControlAction::EnterDeconstruct: return Strings::tr("Deconstruct mode");
|
||||||
|
case ControlAction::CopyTemporary: return Strings::tr("Copy to temporary blueprint");
|
||||||
|
case ControlAction::CreateBlueprint: return Strings::tr("Create blueprint");
|
||||||
|
case ControlAction::Place: return Strings::tr("Place");
|
||||||
|
case ControlAction::ApplySettings: return Strings::tr("Apply settings");
|
||||||
|
case ControlAction::PlaceBeltLine: return Strings::tr("Place belt line");
|
||||||
|
case ControlAction::Rotate: return Strings::tr("Rotate");
|
||||||
|
case ControlAction::CancelBeltLine: return Strings::tr("Cancel belt line");
|
||||||
|
case ControlAction::ExitMode:
|
||||||
|
return context.mode == BuildMode::Deconstruct
|
||||||
|
? Strings::tr("Exit deconstruct mode")
|
||||||
|
: Strings::tr("Exit placement");
|
||||||
|
case ControlAction::ToggleDeconstruct: return Strings::tr("Toggle deconstruct");
|
||||||
|
case ControlAction::DeconstructArea: return Strings::tr("Deconstruct area");
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString getControlContextName(ControlContextKind kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ControlContextKind::General: return Strings::tr("GENERAL");
|
||||||
|
case ControlContextKind::Selection: return Strings::tr("SELECTION");
|
||||||
|
case ControlContextKind::Build: return Strings::tr("BUILD MODE");
|
||||||
|
case ControlContextKind::Blueprint: return Strings::tr("BLUEPRINT MODE");
|
||||||
|
case ControlContextKind::Deconstruct: return Strings::tr("DECONSTRUCT MODE");
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
26
src/ui/ControlActionText.h
Normal file
26
src/ui/ControlActionText.h
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "ControlAction.h"
|
||||||
|
|
||||||
|
// What the player is shown for the actions and bindings declared in ControlAction.h
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT). Kept out of lib/core deliberately: that file decides what
|
||||||
|
// is available and what triggers it, this one decides what it is called, and only this
|
||||||
|
// half is presentation.
|
||||||
|
//
|
||||||
|
// A badge is rendered from the binding it belongs to rather than written beside it, so
|
||||||
|
// what the panel shows on a chip is what the resolver actually matches. When bindings
|
||||||
|
// become player-configurable, this is the only place that has to learn to spell a
|
||||||
|
// rebound key.
|
||||||
|
|
||||||
|
// The chip text for one binding: "Ctrl+V", "LMB drag", "RMB".
|
||||||
|
QString getControlBindingBadge(const ControlBinding& binding);
|
||||||
|
|
||||||
|
// What the action is called in this context. A few actions are named for their
|
||||||
|
// situation rather than their implementation -- one exit action reads "Exit placement"
|
||||||
|
// in a placement mode and "Exit deconstruct mode" in deconstruct mode.
|
||||||
|
QString getControlActionLabel(ControlAction action, const ControlContext& context);
|
||||||
|
|
||||||
|
// The heading, in the upper case the card shows it in (REQ-UI-CONTROLS-CARD).
|
||||||
|
QString getControlContextName(ControlContextKind kind);
|
||||||
303
src/ui/ControlsPanel.cpp
Normal file
303
src/ui/ControlsPanel.cpp
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
#include "ControlsPanel.h"
|
||||||
|
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "ControlActionText.h"
|
||||||
|
#include "GameWorldView.h"
|
||||||
|
#include "selection/SelectionNames.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
const int kMarginPx = 8; // between the band's edge and the panel
|
||||||
|
const int kCardMarginPx = 8; // inside the panel, around its content
|
||||||
|
const int kRefreshMs = 50; // see the class comment on why this polls
|
||||||
|
|
||||||
|
// Separates the heading's name from its detail, e.g. "BUILD MODE * Assembler".
|
||||||
|
const QChar kHeadingSeparator(0x00B7); // U+00B7 MIDDLE DOT
|
||||||
|
|
||||||
|
// The upper-case heading and caption are tracked out a little so they read as labels
|
||||||
|
// rather than as words. Set on the font because Qt's stylesheets have no letter-spacing
|
||||||
|
// property -- writing one there is silently ignored apart from a warning per widget.
|
||||||
|
QFont makeSpacedFont(QFont font, bool bold, int pointSizeDelta)
|
||||||
|
{
|
||||||
|
font.setBold(bold);
|
||||||
|
if (pointSizeDelta != 0 && font.pointSize() > 0)
|
||||||
|
{
|
||||||
|
font.setPointSize(qMax(1, font.pointSize() + pointSizeDelta));
|
||||||
|
}
|
||||||
|
font.setLetterSpacing(QFont::AbsoluteSpacing, 1.0);
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row: the chips for the bindings, then what the action is called. Built as a plain
|
||||||
|
// widget rather than a class of its own -- it holds no state and answers no questions.
|
||||||
|
QWidget* makeRow(ControlAction action, const ControlContext& context, QWidget* parent)
|
||||||
|
{
|
||||||
|
QWidget* row = new QWidget(parent);
|
||||||
|
QHBoxLayout* layout = new QHBoxLayout(row);
|
||||||
|
layout->setContentsMargins(0, 2, 0, 2);
|
||||||
|
layout->setSpacing(4);
|
||||||
|
|
||||||
|
// Every badge is rendered from the binding the resolver matches, so a chip cannot
|
||||||
|
// claim a key that does nothing (REQ-UI-CONTROLS-ACCURACY). The chips of the row
|
||||||
|
// that leaves the mode are the ones marked, not its label (REQ-UI-CONTROLS-CARD).
|
||||||
|
const bool exitsMode = (action == ControlAction::ExitMode);
|
||||||
|
const std::vector<ControlBinding> bindings = getControlActionBindings(action, context);
|
||||||
|
for (const ControlBinding& binding : bindings)
|
||||||
|
{
|
||||||
|
QLabel* badge = new QLabel(getControlBindingBadge(binding), row);
|
||||||
|
badge->setObjectName(exitsMode ? QStringLiteral("controlBadgeExit")
|
||||||
|
: QStringLiteral("controlBadge"));
|
||||||
|
layout->addWidget(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel* label = new QLabel(getControlActionLabel(action, context), row);
|
||||||
|
label->setObjectName(QStringLiteral("controlLabel"));
|
||||||
|
layout->addSpacing(4);
|
||||||
|
layout->addWidget(label);
|
||||||
|
layout->addStretch(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a freshly built widget to the rows and shows it.
|
||||||
|
//
|
||||||
|
// The show is what makes it count. A widget created under an already-visible parent
|
||||||
|
// starts hidden, and a layout treats a hidden item as empty -- it contributes nothing
|
||||||
|
// to the size hint until something shows it, which otherwise does not happen until the
|
||||||
|
// event loop next runs, long after the panel has measured itself.
|
||||||
|
void addAndShow(QVBoxLayout* layout, QWidget* widget)
|
||||||
|
{
|
||||||
|
layout->addWidget(widget);
|
||||||
|
widget->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ControlsPanel::ControlsPanel(const GameWorldView* view, QWidget* parent)
|
||||||
|
: QWidget(parent)
|
||||||
|
, m_view(view)
|
||||||
|
{
|
||||||
|
// Floats over the rendered world, so it brings its own background to stay legible
|
||||||
|
// over any world content (REQ-UI-CONTROLS-PANEL). Palette colors match the build
|
||||||
|
// button bar's and the selection panel's chrome; like them this is widget chrome
|
||||||
|
// rather than world rendering, so it is deliberately not a visuals.toml color. The
|
||||||
|
// class scoped selector keeps the border on the panel rather than cascading onto
|
||||||
|
// its children.
|
||||||
|
setAttribute(Qt::WA_StyledBackground, true);
|
||||||
|
// Letter spacing is deliberately absent here: Qt's stylesheet syntax has no such
|
||||||
|
// property and warns on every widget it is applied to. The heading and the caption
|
||||||
|
// set it on their QFont instead.
|
||||||
|
setStyleSheet(QStringLiteral(
|
||||||
|
"ControlsPanel { background-color: palette(window);"
|
||||||
|
" border: 1px solid palette(mid); border-radius: 4px; }"
|
||||||
|
"QLabel#controlHeading { color: palette(text); }"
|
||||||
|
"QLabel#controlBadge { border: 1px solid palette(mid); border-radius: 3px;"
|
||||||
|
" padding: 1px 5px; font-family: monospace; color: palette(text); }"
|
||||||
|
"QLabel#controlLabel { color: palette(text); }"
|
||||||
|
// The row that leaves the mode is marked on its chips rather than its label
|
||||||
|
// (REQ-UI-CONTROLS-CARD): the label keeps the ordinary text color, so the row
|
||||||
|
// stays legible whatever the palette, and only the chips carry the warning.
|
||||||
|
//
|
||||||
|
// A literal red because there is no palette role for "destructive" -- the
|
||||||
|
// nearest, bright-text, is white by design, being meant for text over dark
|
||||||
|
// highlights, and was unreadable on this panel's chrome. This value reads on a
|
||||||
|
// light and a dark background alike. It is widget chrome, so like the rest of
|
||||||
|
// this stylesheet it is deliberately not a visuals.toml color.
|
||||||
|
"QLabel#controlBadgeExit { border: 1px solid #c0392b; border-radius: 3px;"
|
||||||
|
" padding: 1px 5px; font-family: monospace; color: #c0392b; }"
|
||||||
|
"QLabel#controlCaption { color: palette(mid); }"));
|
||||||
|
|
||||||
|
QVBoxLayout* outerLayout = new QVBoxLayout(this);
|
||||||
|
outerLayout->setContentsMargins(kCardMarginPx, kCardMarginPx,
|
||||||
|
kCardMarginPx, kCardMarginPx);
|
||||||
|
outerLayout->setSpacing(4);
|
||||||
|
|
||||||
|
m_heading = new QLabel(this);
|
||||||
|
m_heading->setObjectName(QStringLiteral("controlHeading"));
|
||||||
|
m_heading->setCursor(Qt::PointingHandCursor);
|
||||||
|
m_heading->setFont(makeSpacedFont(font(), /*bold*/ true, /*pointSizeDelta*/ 0));
|
||||||
|
outerLayout->addWidget(m_heading);
|
||||||
|
|
||||||
|
m_rows = new QWidget(this);
|
||||||
|
m_rowsLayout = new QVBoxLayout(m_rows);
|
||||||
|
m_rowsLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
m_rowsLayout->setSpacing(0);
|
||||||
|
outerLayout->addWidget(m_rows);
|
||||||
|
|
||||||
|
// Polling rather than subscribing; see the class comment.
|
||||||
|
m_refreshTimer = new QTimer(this);
|
||||||
|
connect(m_refreshTimer, &QTimer::timeout, this, &ControlsPanel::refresh);
|
||||||
|
m_refreshTimer->start(kRefreshMs);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::anchorTo(const QRect& worldRect, const QRect& buildBarRect)
|
||||||
|
{
|
||||||
|
m_worldRect = worldRect;
|
||||||
|
m_buildBarRect = buildBarRect;
|
||||||
|
refit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::mousePressEvent(QMouseEvent* event)
|
||||||
|
{
|
||||||
|
// Only the heading toggles; a click anywhere else is swallowed so it never reaches
|
||||||
|
// the world beneath (REQ-UI-CONTROLS-PANEL).
|
||||||
|
if (m_heading->geometry().contains(event->pos()))
|
||||||
|
{
|
||||||
|
m_collapsed = !m_collapsed;
|
||||||
|
m_rows->setVisible(!m_collapsed);
|
||||||
|
refit();
|
||||||
|
}
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::refresh()
|
||||||
|
{
|
||||||
|
if (!m_view) { return; }
|
||||||
|
|
||||||
|
const ControlContext context = m_view->getControlContext();
|
||||||
|
|
||||||
|
const QString heading = getHeadingText(context);
|
||||||
|
const std::vector<ControlAction> contextActions = getContextActions(context);
|
||||||
|
const std::vector<ControlAction> alwaysActions = getAlwaysAvailableActions(context);
|
||||||
|
|
||||||
|
if (heading == m_shownHeading && contextActions == m_shownContextActions
|
||||||
|
&& alwaysActions == m_shownAlwaysActions)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_shownHeading = heading;
|
||||||
|
m_shownContextActions = contextActions;
|
||||||
|
m_shownAlwaysActions = alwaysActions;
|
||||||
|
rebuild(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::rebuild(const ControlContext& context)
|
||||||
|
{
|
||||||
|
m_heading->setText(m_shownHeading);
|
||||||
|
|
||||||
|
// Rows are rebuilt wholesale rather than reconciled: a context change replaces
|
||||||
|
// nearly all of them, and the panel redraws only when something actually changed.
|
||||||
|
while (QLayoutItem* item = m_rowsLayout->takeAt(0))
|
||||||
|
{
|
||||||
|
delete item->widget();
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ControlAction action : m_shownContextActions)
|
||||||
|
{
|
||||||
|
addAndShow(m_rowsLayout, makeRow(action, context, m_rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The always-available block sits under a divider in every context, the General one
|
||||||
|
// included, so the card is read the same way wherever the player is
|
||||||
|
// (REQ-UI-CONTROLS-CARD).
|
||||||
|
if (!m_shownAlwaysActions.empty())
|
||||||
|
{
|
||||||
|
QFrame* divider = new QFrame(m_rows);
|
||||||
|
divider->setFrameShape(QFrame::HLine);
|
||||||
|
divider->setFrameShadow(QFrame::Plain);
|
||||||
|
m_rowsLayout->addSpacing(6);
|
||||||
|
addAndShow(m_rowsLayout, divider);
|
||||||
|
|
||||||
|
QLabel* caption = new QLabel(tr("ALWAYS AVAILABLE"), m_rows);
|
||||||
|
caption->setObjectName(QStringLiteral("controlCaption"));
|
||||||
|
caption->setFont(makeSpacedFont(font(), /*bold*/ false, /*pointSizeDelta*/ -1));
|
||||||
|
addAndShow(m_rowsLayout, caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ControlAction action : m_shownAlwaysActions)
|
||||||
|
{
|
||||||
|
addAndShow(m_rowsLayout, makeRow(action, context, m_rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
m_rows->setVisible(!m_collapsed);
|
||||||
|
refit();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ControlsPanel::getHeadingText(const ControlContext& context) const
|
||||||
|
{
|
||||||
|
const ControlContextKind kind = getControlContextKind(context);
|
||||||
|
const QString name = getControlContextName(kind);
|
||||||
|
|
||||||
|
QString detail;
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case ControlContextKind::Build:
|
||||||
|
detail = getBuildingTypeName(context.builderType);
|
||||||
|
break;
|
||||||
|
case ControlContextKind::Blueprint:
|
||||||
|
{
|
||||||
|
// The temporary blueprint is never named (REQ-UI-BLUEPRINT-TEMP), so it is
|
||||||
|
// labelled by what it is rather than left blank.
|
||||||
|
const QString blueprintName = m_view->getActiveBlueprintName();
|
||||||
|
detail = blueprintName.isEmpty() ? tr("Temporary") : blueprintName;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ControlContextKind::Selection:
|
||||||
|
detail = context.selection == ControlSelection::Buildings
|
||||||
|
? tr("%n building(s)", "", context.selectionCount)
|
||||||
|
: tr("%n object(s)", "", context.selectionCount);
|
||||||
|
break;
|
||||||
|
case ControlContextKind::General:
|
||||||
|
case ControlContextKind::Deconstruct:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.isEmpty()) { return name; }
|
||||||
|
return name + QStringLiteral(" ") + kHeadingSeparator + QStringLiteral(" ") + detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ControlsPanel::refit()
|
||||||
|
{
|
||||||
|
if (m_worldRect.isNull()) { return; }
|
||||||
|
|
||||||
|
// Rows are torn down and rebuilt wholesale, and a widget added to a layout is only
|
||||||
|
// shown once that layout runs -- without this the new rows count for nothing and
|
||||||
|
// the card is measured for the context before it. The polish belongs to the same
|
||||||
|
// step: a freshly created label reports an unstyled size hint until the stylesheet
|
||||||
|
// has reached it, and the badge chips carry border and padding that change it.
|
||||||
|
m_rows->ensurePolished();
|
||||||
|
m_rowsLayout->invalidate();
|
||||||
|
m_rowsLayout->activate();
|
||||||
|
|
||||||
|
// Deliberately not activating the panel's own layout here. That lays the heading
|
||||||
|
// and the rows out inside the geometry the panel still has from the previous
|
||||||
|
// context, which is the wrong frame of reference for choosing the new one.
|
||||||
|
// totalSizeHint answers "how big does this need to be" without reference to how big
|
||||||
|
// it currently is; setGeometry below then re-runs the outer layout on its own.
|
||||||
|
layout()->invalidate();
|
||||||
|
const QSize needed = layout()->totalSizeHint();
|
||||||
|
|
||||||
|
const QRect band = m_worldRect.adjusted(kMarginPx, kMarginPx, -kMarginPx, -kMarginPx);
|
||||||
|
if (band.width() <= 0 || band.height() <= 0) { return; }
|
||||||
|
|
||||||
|
// The bottom-left corner of the view, growing upward as rows are added
|
||||||
|
// (REQ-UI-CONTROLS-PANEL).
|
||||||
|
const int widthPx = qMin(needed.width(), band.width());
|
||||||
|
int heightPx = qMin(needed.height(), band.height());
|
||||||
|
int topPx = band.bottom() - heightPx + 1;
|
||||||
|
|
||||||
|
// The build button bar is centered and sized to its buttons, so it usually leaves
|
||||||
|
// this corner free and the panel can share the bottom edge with it. Only when the
|
||||||
|
// two would actually overlap does the panel rise, clearing the bar's top by the
|
||||||
|
// same margin it keeps from the view's edges (REQ-UI-BUILD-BAR).
|
||||||
|
const QRect wanted(band.left(), topPx, widthPx, heightPx);
|
||||||
|
if (!m_buildBarRect.isNull() && wanted.intersects(m_buildBarRect))
|
||||||
|
{
|
||||||
|
const int availablePx = m_buildBarRect.top() - kMarginPx - band.top();
|
||||||
|
heightPx = qMin(heightPx, qMax(0, availablePx));
|
||||||
|
topPx = m_buildBarRect.top() - kMarginPx - heightPx;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeometry(band.left(), topPx, widthPx, heightPx);
|
||||||
|
}
|
||||||
86
src/ui/ControlsPanel.h
Normal file
86
src/ui/ControlsPanel.h
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QRect>
|
||||||
|
#include <QString>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "ControlAction.h"
|
||||||
|
|
||||||
|
class GameWorldView;
|
||||||
|
class QLabel;
|
||||||
|
class QTimer;
|
||||||
|
class QVBoxLayout;
|
||||||
|
|
||||||
|
// Shows the controls available in the player's current situation
|
||||||
|
// (REQ-UI-CONTROLS-PANEL). The panel decides nothing: which rows apply is
|
||||||
|
// ControlAction.h's answer, the same one the key handling and the world view's mouse
|
||||||
|
// dispatch act on, so what is shown and what happens cannot part company
|
||||||
|
// (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// It floats over the game world at the left edge, bottom-aligned within the band its
|
||||||
|
// owner hands it, and collapses to its heading when the heading is clicked.
|
||||||
|
//
|
||||||
|
// Refreshed on a timer rather than by subscribing to events: two of the things that
|
||||||
|
// change a row -- a belt drag starting, the ghost moving over a transfer target --
|
||||||
|
// happen on mouse movement and publish nothing, and they still have to be reflected
|
||||||
|
// while the game is paused, so there is no tick to hang it on either. The rebuild is
|
||||||
|
// skipped unless the resolved content actually differs, which is a vector of enums to
|
||||||
|
// compare.
|
||||||
|
class ControlsPanel : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Neither pointer is owned; both must outlive this widget. The view is the source
|
||||||
|
// of the control context, being the widget that owns the build mode and the
|
||||||
|
// selection.
|
||||||
|
ControlsPanel(const GameWorldView* view, QWidget* parent = nullptr);
|
||||||
|
|
||||||
|
// Places the panel in the bottom-left corner of the game world view. It shares the
|
||||||
|
// bottom edge with the build button bar rather than clearing its strip, the bar
|
||||||
|
// being centered and sized to its buttons and so usually leaving the left free; it
|
||||||
|
// rises above the bar only when the two would otherwise overlap. `buildBarRect` is
|
||||||
|
// the bar's current geometry in the same coordinates, or a null rect when there is
|
||||||
|
// none to avoid (REQ-UI-CONTROLS-PANEL, REQ-UI-BUILD-BAR).
|
||||||
|
void anchorTo(const QRect& worldRect, const QRect& buildBarRect);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
// Clicking the heading collapses and expands the panel (REQ-UI-CONTROLS-PANEL).
|
||||||
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Re-resolves the context and rebuilds only if the rows or the heading changed.
|
||||||
|
void refresh();
|
||||||
|
// Replaces the rows with those of the current context.
|
||||||
|
void rebuild(const ControlContext& context);
|
||||||
|
// The heading's "<name> * <detail>" text for the context, detail omitted when the
|
||||||
|
// context has none.
|
||||||
|
QString getHeadingText(const ControlContext& context) const;
|
||||||
|
// Re-fits the panel to its content within the anchored band.
|
||||||
|
void refit();
|
||||||
|
|
||||||
|
const GameWorldView* m_view;
|
||||||
|
|
||||||
|
QLabel* m_heading;
|
||||||
|
QWidget* m_rows;
|
||||||
|
QVBoxLayout* m_rowsLayout;
|
||||||
|
QTimer* m_refreshTimer;
|
||||||
|
|
||||||
|
// What is currently drawn, so a refresh that resolves to the same thing does
|
||||||
|
// nothing. The always-available block is kept separately because the divider
|
||||||
|
// between the two is part of what is drawn.
|
||||||
|
QString m_shownHeading;
|
||||||
|
std::vector<ControlAction> m_shownContextActions;
|
||||||
|
std::vector<ControlAction> m_shownAlwaysActions;
|
||||||
|
|
||||||
|
// Survives context changes and simulation restarts; presentation only, never a
|
||||||
|
// command (REQ-UI-CONTROLS-PANEL).
|
||||||
|
bool m_collapsed = false;
|
||||||
|
|
||||||
|
// The world view the panel sits in, and the build button bar it steps around, both
|
||||||
|
// in the coordinates of its parent. Null until the owner has anchored it.
|
||||||
|
QRect m_worldRect;
|
||||||
|
QRect m_buildBarRect;
|
||||||
|
};
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@
|
|||||||
#include <QVector2D>
|
#include <QVector2D>
|
||||||
|
|
||||||
#include "Blueprint.h"
|
#include "Blueprint.h"
|
||||||
#include "BuildingConfig.h"
|
#include "BuildModeController.h"
|
||||||
#include "BlueprintModeExitedEvent.h"
|
#include "BlueprintModeExitedEvent.h"
|
||||||
#include "BlueprintPlacementRequestedEvent.h"
|
#include "BlueprintPlacementRequestedEvent.h"
|
||||||
#include "BuilderModeExitedEvent.h"
|
#include "BuilderModeExitedEvent.h"
|
||||||
@@ -31,6 +31,13 @@
|
|||||||
#include "EventHandler.h"
|
#include "EventHandler.h"
|
||||||
#include "ExitBlueprintModeRequestedEvent.h"
|
#include "ExitBlueprintModeRequestedEvent.h"
|
||||||
#include "ExitBuilderModeRequestedEvent.h"
|
#include "ExitBuilderModeRequestedEvent.h"
|
||||||
|
#include "DebugDrawToggleRequestedEvent.h"
|
||||||
|
#include "GhostRotationRequestedEvent.h"
|
||||||
|
#include "InputMapper.h"
|
||||||
|
#include "ModeCancelRequestedEvent.h"
|
||||||
|
#include "PanDirectionChangedEvent.h"
|
||||||
|
#include "PauseToggleRequestedEvent.h"
|
||||||
|
#include "SpeedStepRequestedEvent.h"
|
||||||
#include "DebugDrawToggledEvent.h"
|
#include "DebugDrawToggledEvent.h"
|
||||||
#include "ArtifactCountChangedEvent.h"
|
#include "ArtifactCountChangedEvent.h"
|
||||||
#include "BeamFiredEvent.h"
|
#include "BeamFiredEvent.h"
|
||||||
@@ -44,31 +51,24 @@
|
|||||||
#include "CommandManager.h"
|
#include "CommandManager.h"
|
||||||
#include "EntitySelectionChangedEvent.h"
|
#include "EntitySelectionChangedEvent.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
|
#include "PlacementRules.h"
|
||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
|
#include "SelectionController.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "TickDriver.h"
|
#include "TickDriver.h"
|
||||||
#include "TunnelCompletion.h"
|
#include "TunnelCompletion.h"
|
||||||
#include "VisualsConfig.h"
|
#include "VisualsConfig.h"
|
||||||
|
#include "WorldCamera.h"
|
||||||
|
#include "WorldCoordinates.h"
|
||||||
|
#include "WorldRenderer.h"
|
||||||
|
|
||||||
struct Command;
|
struct Command;
|
||||||
struct ParsedReplay;
|
struct ParsedReplay;
|
||||||
|
class BlueprintLibrary;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
class ReplayPlayer;
|
class ReplayPlayer;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
class QPainter;
|
class QPainter;
|
||||||
class QSvgRenderer;
|
|
||||||
|
|
||||||
struct QPointCompare
|
|
||||||
{
|
|
||||||
bool operator()(const QPoint& a, const QPoint& b) const
|
|
||||||
{
|
|
||||||
if (a.x() != b.x()) { return a.x() < b.x(); }
|
|
||||||
return a.y() < b.y();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Tunnel entries/exits indexed by their single-cell tile (REQ-BLD-TUNNEL-MODE).
|
|
||||||
using TunnelTileMap = std::map<QPoint, TunnelTileInfo, QPointCompare>;
|
|
||||||
|
|
||||||
class GameWorldView : public QOpenGLWidget,
|
class GameWorldView : public QOpenGLWidget,
|
||||||
public CombinedEventHandler<BeamFiredEvent,
|
public CombinedEventHandler<BeamFiredEvent,
|
||||||
@@ -78,6 +78,12 @@ class GameWorldView : public QOpenGLWidget,
|
|||||||
BlueprintPlacementRequestedEvent,
|
BlueprintPlacementRequestedEvent,
|
||||||
ExitBlueprintModeRequestedEvent,
|
ExitBlueprintModeRequestedEvent,
|
||||||
SpeedChangeRequestedEvent,
|
SpeedChangeRequestedEvent,
|
||||||
|
PanDirectionChangedEvent,
|
||||||
|
PauseToggleRequestedEvent,
|
||||||
|
SpeedStepRequestedEvent,
|
||||||
|
GhostRotationRequestedEvent,
|
||||||
|
ModeCancelRequestedEvent,
|
||||||
|
DebugDrawToggleRequestedEvent,
|
||||||
CommandRequestedEvent>
|
CommandRequestedEvent>
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@@ -97,11 +103,32 @@ public:
|
|||||||
void setGameSpeed(double multiplier);
|
void setGameSpeed(double multiplier);
|
||||||
void resetForNewGame();
|
void resetForNewGame();
|
||||||
|
|
||||||
|
// The blueprint library is constructed after this widget, so it arrives by setter.
|
||||||
|
// Not owned; supplies the two facts about blueprints that the control context needs
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
void setBlueprintLibrary(const BlueprintLibrary* library);
|
||||||
|
|
||||||
|
// The player's current situation, as the one snapshot every reader of the control
|
||||||
|
// table works from: this widget's own mouse dispatch, the input mapper, and the
|
||||||
|
// controls panel (REQ-UI-CONTROLS-CONTENT). Built here because this widget owns the
|
||||||
|
// build mode and the selection.
|
||||||
|
ControlContext getControlContext() const;
|
||||||
|
|
||||||
|
// Name of the blueprint being placed, for the controls panel's heading. Empty while
|
||||||
|
// no blueprint is active and for the unnamed temporary one (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
QString getActiveBlueprintName() const;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void initializeGL() override;
|
void initializeGL() override;
|
||||||
void paintGL() override;
|
void paintGL() override;
|
||||||
|
// Only forwards to the input mapper; every key this widget acts on reaches it
|
||||||
|
// as a published action instead (REQ-UI-HOTKEYS).
|
||||||
void keyPressEvent(QKeyEvent* event) override;
|
void keyPressEvent(QKeyEvent* event) override;
|
||||||
void keyReleaseEvent(QKeyEvent* event) override;
|
void keyReleaseEvent(QKeyEvent* event) override;
|
||||||
|
// Key-up never arrives for a key that was still held when focus moved away —
|
||||||
|
// to a modal dialog, another widget, or another window. Any held action would
|
||||||
|
// otherwise stay held forever, so focus loss drops all of them.
|
||||||
|
void focusOutEvent(QFocusEvent* event) override;
|
||||||
void mousePressEvent(QMouseEvent* event) override;
|
void mousePressEvent(QMouseEvent* event) override;
|
||||||
void mouseMoveEvent(QMouseEvent* event) override;
|
void mouseMoveEvent(QMouseEvent* event) override;
|
||||||
void mouseReleaseEvent(QMouseEvent* event) override;
|
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||||
@@ -117,6 +144,12 @@ private:
|
|||||||
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const PanDirectionChangedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const PauseToggleRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const SpeedStepRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const GhostRotationRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const ModeCancelRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const DebugDrawToggleRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
|
||||||
|
|
||||||
// Enqueue a sim command onto the CommandManager (the single mutation path).
|
// Enqueue a sim command onto the CommandManager (the single mutation path).
|
||||||
@@ -129,29 +162,13 @@ private:
|
|||||||
// Used to pre-validate placements whose UI follow-up depends on success.
|
// Used to pre-validate placements whose UI follow-up depends on success.
|
||||||
bool canAfford(BuildingType type) const;
|
bool canAfford(BuildingType type) const;
|
||||||
|
|
||||||
void drawTiles(QPainter& painter);
|
// Screen-anchored chrome, drawn after the world (see WorldRenderer): these
|
||||||
void drawPortItems(QPainter& painter);
|
// need no world transform, which is exactly why they stayed here.
|
||||||
void drawBuildings(QPainter& painter);
|
|
||||||
void drawSelectionHighlights(QPainter& painter);
|
|
||||||
void drawCopyConfigFeedback(QPainter& painter);
|
|
||||||
void drawStations(QPainter& painter);
|
|
||||||
void drawBeltItems(QPainter& painter);
|
|
||||||
// Draws a single item centered at widget-space `center`, spanning `halfPx` in
|
|
||||||
// each direction (a half-tile). Uses the item's icon when one exists
|
|
||||||
// (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from
|
|
||||||
// visuals.toml. Shared by drawBeltItems and drawPortItems.
|
|
||||||
void drawWorldItem(QPainter& painter, const std::string& itemId,
|
|
||||||
QPointF center, float halfPx);
|
|
||||||
void drawDebris(QPainter& painter);
|
|
||||||
void drawShips(QPainter& painter);
|
|
||||||
void drawHpBar(QPainter& painter, qreal left, qreal top, qreal width,
|
|
||||||
float fraction, bool isEnemy);
|
|
||||||
void drawDebugSensorRanges(QPainter& painter);
|
|
||||||
void drawDebugTargetLines(QPainter& painter);
|
|
||||||
void drawDebugOverlay(QPainter& painter);
|
|
||||||
void drawBeams(QPainter& painter);
|
|
||||||
void drawOverlays(QPainter& painter);
|
|
||||||
void drawScreenSpace(QPainter& painter);
|
void drawScreenSpace(QPainter& painter);
|
||||||
|
// Threat and production readout shown while debug draw is on (F3). Positioned
|
||||||
|
// in pixels at the top-left corner, so it belongs with the chrome rather than
|
||||||
|
// with the world.
|
||||||
|
void drawDebugOverlay(QPainter& painter);
|
||||||
// Vignette-style border shown while the game is paused (speed 0x, REQ-UI-PAUSE-BORDER)
|
// Vignette-style border shown while the game is paused (speed 0x, REQ-UI-PAUSE-BORDER)
|
||||||
// to make the paused state hard to miss: a black frame whose alpha fades from 50% at
|
// to make the paused state hard to miss: a black frame whose alpha fades from 50% at
|
||||||
// the viewport edges to 0% toward the center.
|
// the viewport edges to 0% toward the center.
|
||||||
@@ -166,133 +183,70 @@ private:
|
|||||||
void drawVignetteBorder(QPainter& painter, const QColor& edgeColor);
|
void drawVignetteBorder(QPainter& painter, const QColor& edgeColor);
|
||||||
void drawReplayOverlay(QPainter& painter);
|
void drawReplayOverlay(QPainter& painter);
|
||||||
|
|
||||||
float getTilePx() const;
|
// Gathers the interaction state the renderer needs for this frame.
|
||||||
float getViewportWidthTiles() const;
|
WorldRenderFrame makeRenderFrame() const;
|
||||||
// World-X (tiles) at the left edge of the viewport. m_scrollXTiles stores the
|
|
||||||
// view center; this derives the left edge the world<->widget conversions need.
|
// The world <-> widget transform for the current viewport size and scroll
|
||||||
float getViewLeftTiles() const;
|
// position. Cheap to build and deliberately not cached: it is a snapshot that
|
||||||
QPointF worldToWidget(QVector2D worldPos) const;
|
// a resize or a scroll invalidates, so every user takes a fresh one.
|
||||||
QPointF tileToWidget(QPoint tile) const;
|
WorldCoordinates getCoordinates() const;
|
||||||
QPoint widgetToTile(QPoint widgetPt) const;
|
|
||||||
QRectF tileRect(QPoint tile) const;
|
|
||||||
QRect getViewportRect() const;
|
|
||||||
// Widget-space rectangle covering a building or construction site's footprint,
|
|
||||||
// or nullopt if the id resolves to neither. Shared by the selection highlight
|
|
||||||
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
|
|
||||||
std::optional<QRectF> footprintWidgetRect(BuildingId id) const;
|
|
||||||
|
|
||||||
float getAsteroidLeftEdge() const;
|
float getAsteroidLeftEdge() const;
|
||||||
float getEnemyStationRightEdge() const;
|
float getEnemyStationRightEdge() const;
|
||||||
// Horizontal pan speed at a given view-center X, in tiles/s (REQ-UI-SCROLL-SPEED).
|
// The camera's current pan limits, read fresh from the simulation each frame:
|
||||||
float panSpeedTilesPerSecondAt(float viewCenterXTiles) const;
|
// both edges move with asteroid expansion and with pushes (REQ-GW-SCROLL-LIMIT).
|
||||||
void clampScroll();
|
ScrollBounds getScrollBounds() const;
|
||||||
|
|
||||||
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
|
// canPlaceBuilding (PlacementRules) against this view's simulation.
|
||||||
|
bool canPlaceBuildingHere(BuildingType type, QPoint anchor, Rotation rot) const;
|
||||||
std::optional<BuildingId> buildingAtTile(QPoint tile) const;
|
std::optional<BuildingId> buildingAtTile(QPoint tile) const;
|
||||||
std::optional<BuildingId> siteAtTile(QPoint tile) const;
|
std::optional<BuildingId> siteAtTile(QPoint tile) const;
|
||||||
// Ids of all buildings and construction sites whose footprint intersects
|
|
||||||
// the tile box spanned by the two (unordered) corner tiles.
|
|
||||||
std::vector<BuildingId> buildingsInBox(QPoint cornerA, QPoint cornerB) const;
|
|
||||||
QVector2D widgetToWorld(QPoint widgetPt) const;
|
|
||||||
|
|
||||||
void drawPortGlyph(QPainter& painter, QPoint tile,
|
|
||||||
Rotation direction, const QColor& color,
|
|
||||||
bool centered);
|
|
||||||
|
|
||||||
void drawBuildingGhost(QPainter& painter, BuildingType type,
|
|
||||||
QPoint anchorTile, Rotation rotation, bool valid,
|
|
||||||
bool showPortTargetGlyphs);
|
|
||||||
|
|
||||||
// Loads the per-building world icons (REQ-UI-WORLD-ICON) from
|
|
||||||
// <configDir>/../icons/buildings once at construction. Only the building
|
|
||||||
// types with a world icon are loaded (production buildings, HQ, stations);
|
|
||||||
// belts, splitters, and tunnels are deliberately excluded so their
|
|
||||||
// orientation stays readable. The SVG's chip background is stripped; the
|
|
||||||
// glyph is pre-rendered in both white and dark ink for auto-contrast.
|
|
||||||
void loadBuildingIcons(const std::string& configDir);
|
|
||||||
// Draws a building's world icon glyph centered in box, choosing the white or
|
|
||||||
// dark pre-rendered variant by fill luminance so it stays legible. Returns
|
|
||||||
// false if the type has no world icon (caller falls back to the text glyph).
|
|
||||||
bool drawBuildingIcon(QPainter& painter, BuildingType type,
|
|
||||||
const QRectF& box, const QColor& fill) const;
|
|
||||||
|
|
||||||
void placeBlueprintAtTile(QPoint center);
|
void placeBlueprintAtTile(QPoint center);
|
||||||
|
// resolveBlueprintGhost (PlacementRules) for one building of the blueprint currently
|
||||||
|
// in placement mode, anchored at the cursor tile `center`.
|
||||||
|
BlueprintGhostResolved resolveBlueprintGhostHere(const BlueprintBuilding& building,
|
||||||
|
QPoint center) const;
|
||||||
|
// The blueprint's stored recipe or schematic id, or empty when it stores none or the
|
||||||
|
// stored one is currently locked (REQ-LOCK-UI-BLUEPRINT).
|
||||||
|
std::string unlockedRecipeId(const BlueprintBuilding& building) const;
|
||||||
|
// Applies a single-building blueprint's settings to the building it is hovering,
|
||||||
|
// instead of placing anything (REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
void transferConfigTo(BuildingId id, const BlueprintBuilding& source);
|
||||||
|
|
||||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
// Drops despawned or fully-collected debris from the selection
|
||||||
// Clears the debris selection, emitting an empty DebrisSelectionChangedEvent when
|
// (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
|
||||||
// it was non-empty (REQ-UI-DEBRIS-CLICK-SELECT). Used when another selection
|
|
||||||
// category takes over.
|
|
||||||
void clearDebrisSelection();
|
|
||||||
// Drops despawned or fully-collected debris from the selection and re-emits
|
|
||||||
// when it changed (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
|
|
||||||
void pruneDespawnedDebris();
|
void pruneDespawnedDebris();
|
||||||
// Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was
|
// Drops despawned or dead actors from the selection (REQ-UI-ENTITY-CLICK-SELECT).
|
||||||
// non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over.
|
// Called each frame from onFrame().
|
||||||
void clearEntitySelection();
|
|
||||||
// Drops despawned or dead actors from the selection and re-emits when it changed
|
|
||||||
// (REQ-UI-ENTITY-CLICK-SELECT). Called each frame from onFrame().
|
|
||||||
void pruneDespawnedActors();
|
void pruneDespawnedActors();
|
||||||
// True if the given actor is part of the current actor selection.
|
// Resolves a click into the selection it should produce, applying the category
|
||||||
bool isEntitySelected(entt::entity entity) const;
|
// precedence of REQ-UI-SELECTION-CATEGORIES; the controller owns what that then
|
||||||
|
// does to the existing selection. Returns false when the click hit nothing,
|
||||||
|
// which is the only case that goes on to start a box drag.
|
||||||
|
bool selectAtPoint(QPoint tile, QVector2D worldPos, bool additive);
|
||||||
|
void selectInBox(bool additive);
|
||||||
void stepSpeed(int delta);
|
void stepSpeed(int delta);
|
||||||
void placeAtTile(QPoint tile);
|
void placeAtTile(QPoint tile);
|
||||||
|
|
||||||
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE). Active while the builder type
|
// Re-resolves the tunnel ghost type and completion partner from the current
|
||||||
// is TunnelEntry; the ghost then resolves to an entry or exit by hovered position.
|
// ghost tile, rotation, and sub-tile cursor position, storing both on the build
|
||||||
bool inTunnelMode() const;
|
// mode controller. Only meaningful in tunnel mode (REQ-BLD-TUNNEL-MODE).
|
||||||
// The building type the ghost currently represents: the position-resolved tunnel
|
|
||||||
// type in tunnel mode, otherwise the plain builder type.
|
|
||||||
BuildingType effectiveBuilderType() const;
|
|
||||||
// Recomputes m_tunnelGhostType and m_tunnelPartnerTile from the current ghost
|
|
||||||
// tile, rotation, and sub-tile cursor position. Only meaningful in tunnel mode.
|
|
||||||
void updateTunnelGhost();
|
void updateTunnelGhost();
|
||||||
// Indexes every tunnel entry/exit — built or still a construction site — by its
|
|
||||||
// single-cell tile. Shared by the placement preview and the selection highlight.
|
|
||||||
TunnelTileMap collectTunnelTiles() const;
|
|
||||||
// Wraps a tunnel tile index in the lookup functor the TunnelCompletion helpers
|
|
||||||
// take. The returned functor references `tunnels`, which must outlive it.
|
|
||||||
static TunnelLookup makeTunnelLookup(const TunnelTileMap& tunnels);
|
|
||||||
// Draws the green connection highlight for every selected tunnel end that has a
|
|
||||||
// matching end (REQ-BLD-TUNNEL-SELECT-HIGHLIGHT).
|
|
||||||
void drawSelectedTunnelConnections(QPainter& painter);
|
|
||||||
|
|
||||||
// Belt drag placement (REQ-BLD-BELT-DRAG).
|
// Belt drag placement (REQ-BLD-BELT-DRAG).
|
||||||
// Per-path-tile decision, shared by ghost drawing and release-time placement.
|
// Recomputes the drag path from its anchor to cursorTile using the current ghost
|
||||||
enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid };
|
// orientation, and stores it on the build mode controller.
|
||||||
struct BeltDragResolved
|
|
||||||
{
|
|
||||||
BeltTileAction action;
|
|
||||||
bool affordable; // meaningful only for PlaceNew
|
|
||||||
std::optional<BuildingId> rotateId; // set only for RotateInPlace
|
|
||||||
};
|
|
||||||
// Recomputes m_beltDragPath from m_beltDragAnchor to cursorTile using the
|
|
||||||
// current ghost orientation.
|
|
||||||
void recomputeBeltDragPath(QPoint cursorTile);
|
void recomputeBeltDragPath(QPoint cursorTile);
|
||||||
// Classifies each path tile against the current sim state, applying cumulative
|
// resolveBeltDragPath (PlacementRules) against this view's simulation.
|
||||||
// affordability to the PlaceNew tiles.
|
|
||||||
std::vector<BeltDragResolved> resolveBeltDragPath() const;
|
std::vector<BeltDragResolved> resolveBeltDragPath() const;
|
||||||
// Enqueues placements and rotate-in-place commands for the resolved path.
|
// Enqueues placements and rotate-in-place commands for the resolved path.
|
||||||
void applyBeltDragPath();
|
void applyBeltDragPath();
|
||||||
|
|
||||||
// Copy-settings gesture (REQ-BLD-COPY-CONFIG): Shift+right-click copies a
|
// Turns the ghost and refreshes everything that depends on its facing: placement
|
||||||
// building's configuration into m_copiedConfig; Shift+left-click applies it to
|
// validity, the tunnel completion match, and an in-progress belt drag's path.
|
||||||
// another building of the same type via the existing configuration commands.
|
// The mode transitions themselves live on m_buildMode.
|
||||||
void copyConfigFrom(BuildingId id);
|
|
||||||
void pasteConfigTo(BuildingId id);
|
|
||||||
|
|
||||||
void enterBuilderMode(BuildingType type);
|
|
||||||
void exitBuilderMode();
|
|
||||||
void enterBlueprintMode(Blueprint blueprint);
|
|
||||||
void exitBlueprintMode();
|
|
||||||
void toggleDeconstructMode();
|
|
||||||
void rotateGhost(bool clockwise);
|
void rotateGhost(bool clockwise);
|
||||||
|
|
||||||
struct ActiveBeam
|
|
||||||
{
|
|
||||||
BeamFiredEvent event;
|
|
||||||
QVector2D targetOffset;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Beam lifetime in game ticks so beams freeze with the simulation when
|
// Beam lifetime in game ticks so beams freeze with the simulation when
|
||||||
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
|
// paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM).
|
||||||
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
|
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
|
||||||
@@ -301,22 +255,6 @@ private:
|
|||||||
const GameConfig* m_config;
|
const GameConfig* m_config;
|
||||||
const VisualsConfig* m_visuals;
|
const VisualsConfig* m_visuals;
|
||||||
|
|
||||||
// World icon glyph renderers per building type (REQ-UI-WORLD-ICON), in a
|
|
||||||
// white and a dark variant so drawBuildingIcon can auto-contrast against the
|
|
||||||
// building's fill. Rendered as vector at the view scale each draw so they
|
|
||||||
// stay crisp. Populated once by loadBuildingIcons().
|
|
||||||
struct BuildingIconRenderers
|
|
||||||
{
|
|
||||||
std::unique_ptr<QSvgRenderer> white;
|
|
||||||
std::unique_ptr<QSvgRenderer> dark;
|
|
||||||
};
|
|
||||||
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
|
|
||||||
|
|
||||||
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
|
|
||||||
// MainWindow. Shared draw path for belt and port items; pixmaps are cached
|
|
||||||
// per target size.
|
|
||||||
ItemIconCache* m_itemIcons;
|
|
||||||
|
|
||||||
// Funnels all player input into the single Simulation::apply chokepoint.
|
// Funnels all player input into the single Simulation::apply chokepoint.
|
||||||
CommandManager m_commandManager;
|
CommandManager m_commandManager;
|
||||||
// A Reset command was enqueued; reset the view after the next drain applies it.
|
// A Reset command was enqueued; reset the view after the next drain applies it.
|
||||||
@@ -325,70 +263,55 @@ private:
|
|||||||
// live input is ignored (the CommandManager is in replay mode).
|
// live input is ignored (the CommandManager is in replay mode).
|
||||||
std::unique_ptr<ReplayPlayer> m_replayPlayer;
|
std::unique_ptr<ReplayPlayer> m_replayPlayer;
|
||||||
|
|
||||||
|
// Draws the world; this widget supplies the interaction state each frame and
|
||||||
|
// keeps only the screen-anchored chrome for itself.
|
||||||
|
std::unique_ptr<WorldRenderer> m_renderer;
|
||||||
|
|
||||||
TickDriver m_tickDriver;
|
TickDriver m_tickDriver;
|
||||||
QElapsedTimer m_frameTimer;
|
QElapsedTimer m_frameTimer;
|
||||||
std::mt19937 m_rng;
|
std::mt19937 m_rng;
|
||||||
double m_gameSpeedMultiplier;
|
double m_gameSpeedMultiplier;
|
||||||
double m_prevNonZeroSpeed;
|
double m_prevNonZeroSpeed;
|
||||||
// World-X (tiles) at the center of the viewport (see getViewLeftTiles()).
|
// Horizontal view position (REQ-UI-SCROLL). Owns the scroll position itself;
|
||||||
float m_scrollXTiles;
|
// this widget only supplies the pan intent and the simulation-derived bounds.
|
||||||
|
WorldCamera m_camera;
|
||||||
|
|
||||||
QTimer* m_renderTimer;
|
QTimer* m_renderTimer;
|
||||||
|
|
||||||
std::vector<ActiveBeam> m_activeBeams;
|
std::vector<ActiveBeam> m_activeBeams;
|
||||||
|
|
||||||
std::optional<BuildingType> m_builderType;
|
// The active build mode (builder / blueprint / deconstruct, or none) and the
|
||||||
Rotation m_ghostRotation;
|
// ghost, tunnel and belt-drag state belonging to it. Owns the transitions
|
||||||
QPoint m_ghostTile;
|
// between them; this widget supplies the parts that need the simulation.
|
||||||
bool m_ghostValid;
|
BuildModeController m_buildMode;
|
||||||
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE): while m_builderType is
|
|
||||||
// TunnelEntry the ghost resolves to an entry or an exit based on the hovered
|
|
||||||
// position; m_tunnelPartnerTile is the existing tunnel it would complete (drawn
|
|
||||||
// as the green connection preview), or unset when there is no completion match.
|
|
||||||
BuildingType m_tunnelGhostType = BuildingType::TunnelEntry;
|
|
||||||
std::optional<QPoint> m_tunnelPartnerTile;
|
|
||||||
// Deferred belt drag placement (REQ-BLD-BELT-DRAG): while dragging, the
|
|
||||||
// rectilinear anchor->cursor path is recomputed on each move and only applied
|
|
||||||
// on release. Empty unless a belt drag is in progress.
|
|
||||||
std::vector<BeltPathTile> m_beltDragPath;
|
|
||||||
QPoint m_beltDragAnchor;
|
|
||||||
// Last known cursor position in world (tile) units; used to pick the belt-drag
|
// Last known cursor position in world (tile) units; used to pick the belt-drag
|
||||||
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG).
|
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG)
|
||||||
|
// and to resolve the tunnel ghost sub-tile (REQ-BLD-TUNNEL-MODE).
|
||||||
QVector2D m_cursorWorldPos;
|
QVector2D m_cursorWorldPos;
|
||||||
bool m_dragging;
|
|
||||||
|
|
||||||
std::optional<Blueprint> m_blueprintMode;
|
|
||||||
QPoint m_blueprintGhostTile;
|
|
||||||
|
|
||||||
// Temporary cache for the copy-settings gesture (REQ-BLD-COPY-CONFIG); held
|
|
||||||
// only while Shift is down and cleared on Shift release.
|
|
||||||
std::optional<BuildingConfig> m_copiedConfig;
|
|
||||||
|
|
||||||
// Brief outline flash shown on a building when settings are copied from it or
|
|
||||||
// pasted onto it (REQ-BLD-COPY-CONFIG-FEEDBACK). remainingMs counts down in
|
|
||||||
// wall-clock time so the flash plays at a fixed length regardless of game speed
|
|
||||||
// (and while paused).
|
|
||||||
struct CopyConfigFlash
|
|
||||||
{
|
|
||||||
BuildingId id;
|
|
||||||
qint64 remainingMs;
|
|
||||||
};
|
|
||||||
std::vector<CopyConfigFlash> m_copyConfigFlashes;
|
|
||||||
static constexpr qint64 kCopyFlashDurationMs = 300;
|
|
||||||
|
|
||||||
bool m_deconstructMode;
|
|
||||||
std::optional<BuildingId> m_deconstructHoverBuildingId;
|
|
||||||
bool m_debugDraw;
|
bool m_debugDraw;
|
||||||
|
|
||||||
std::vector<BuildingId> m_selectedBuildingIds;
|
// Owns the selection across all three categories and the rules for moving
|
||||||
std::vector<entt::entity> m_selectedEntities;
|
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
|
||||||
std::vector<entt::entity> m_selectedDebris;
|
// events. This widget only resolves what was hit.
|
||||||
|
SelectionController m_selection;
|
||||||
|
// Not owned; set after construction, so null until MainWindow has built it.
|
||||||
|
const BlueprintLibrary* m_blueprintLibrary = nullptr;
|
||||||
bool m_boxSelecting;
|
bool m_boxSelecting;
|
||||||
QPoint m_boxStartTile;
|
QPoint m_boxStartTile;
|
||||||
QPoint m_boxCurrentTile;
|
QPoint m_boxCurrentTile;
|
||||||
|
|
||||||
bool m_scrollLeft;
|
// Interprets this widget's key events into semantic actions and publishes them
|
||||||
bool m_scrollRight;
|
// (REQ-UI-HOTKEYS). Owned here for now because this is the widget that holds
|
||||||
|
// focus; everything it produces travels by event, so it can move to a
|
||||||
|
// window-wide owner later without touching its consumers.
|
||||||
|
InputMapper m_inputMapper;
|
||||||
|
|
||||||
|
// Latest pan direction published by the input mapper (REQ-UI-SCROLL), fed to
|
||||||
|
// the camera each frame. Cached from the event payload rather than re-read:
|
||||||
|
// input has no other source of truth (see PanDirectionChangedEvent).
|
||||||
|
PanDirection m_panDirection = PanDirection::None;
|
||||||
bool m_gameOverShown;
|
bool m_gameOverShown;
|
||||||
bool m_winShown;
|
bool m_winShown;
|
||||||
bool m_schematicChoiceShown;
|
bool m_schematicChoiceShown;
|
||||||
|
|||||||
@@ -21,13 +21,6 @@
|
|||||||
#include "SpeedChangeRequestedEvent.h"
|
#include "SpeedChangeRequestedEvent.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
|
|
||||||
// word in the header stock and expand button (REQ-UI-BLOCKS-ICON).
|
|
||||||
const char* const kBlockItemId = "building_block";
|
|
||||||
}
|
|
||||||
|
|
||||||
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
||||||
const int HeaderBar::kSpeedCount = 5;
|
const int HeaderBar::kSpeedCount = 5;
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,11 @@ QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
|
|||||||
|
|
||||||
return pixmap;
|
return pixmap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QSize getLogicalSize(const QPixmap& pixmap)
|
||||||
|
{
|
||||||
|
if (pixmap.isNull()) { return QSize(0, 0); }
|
||||||
|
const qreal dpr = pixmap.devicePixelRatio();
|
||||||
|
return QSize(static_cast<int>(pixmap.width() / dpr),
|
||||||
|
static_cast<int>(pixmap.height() / dpr));
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
|
#include <QSize>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
// Renders `text` followed by `icon` to its right, vertically centered, onto a
|
// Renders `text` followed by `icon` to its right, vertically centered, onto a
|
||||||
@@ -14,3 +15,14 @@
|
|||||||
// theme-correct; the result is devicePixelRatio-aware so text and icon stay crisp.
|
// theme-correct; the result is devicePixelRatio-aware so text and icon stay crisp.
|
||||||
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
|
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
|
||||||
const QFont& font, const QColor& textColor);
|
const QFont& font, const QColor& textColor);
|
||||||
|
|
||||||
|
// A pixmap's size in device-independent pixels. The pixmaps composed by
|
||||||
|
// renderCaptionWithIcon and its callers are rasterized at the device pixel ratio, so
|
||||||
|
// their raw size is not the size to lay them out at.
|
||||||
|
QSize getLogicalSize(const QPixmap& pixmap);
|
||||||
|
|
||||||
|
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
|
||||||
|
// word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
|
||||||
|
// REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of
|
||||||
|
// one is a caller of the other.
|
||||||
|
const char* const kBlockItemId = "building_block";
|
||||||
|
|||||||
225
src/ui/InputMapper.cpp
Normal file
225
src/ui/InputMapper.cpp
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
#include "InputMapper.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include <QKeyEvent>
|
||||||
|
|
||||||
|
#include "BlueprintSaveRequestedEvent.h"
|
||||||
|
#include "BlueprintSelectionRequestedEvent.h"
|
||||||
|
#include "BuildHotkeyPressedEvent.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "ControlAction.h"
|
||||||
|
#include "DebugDrawToggleRequestedEvent.h"
|
||||||
|
#include "EscapeMenuRequestedEvent.h"
|
||||||
|
#include "EventManager.h"
|
||||||
|
#include "GhostRotationRequestedEvent.h"
|
||||||
|
#include "ModeCancelRequestedEvent.h"
|
||||||
|
#include "PanDirectionChangedEvent.h"
|
||||||
|
#include "PauseToggleRequestedEvent.h"
|
||||||
|
#include "SpeedStepRequestedEvent.h"
|
||||||
|
#include "TemporaryBlueprintCaptureRequestedEvent.h"
|
||||||
|
#include "TemporaryBlueprintPlaceRequestedEvent.h"
|
||||||
|
#include "TracePrintRequestedEvent.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// The building type a number-key build hotkey selects, or nullopt for the digits
|
||||||
|
// that are unbound (REQ-UI-HOTKEYS). Plain digits pick the transport buildings,
|
||||||
|
// Shift+digit the production ones.
|
||||||
|
std::optional<BuildingType> buildHotkeyType(int digit, bool shiftHeld)
|
||||||
|
{
|
||||||
|
if (!shiftHeld)
|
||||||
|
{
|
||||||
|
switch (digit)
|
||||||
|
{
|
||||||
|
case 1: return BuildingType::Belt;
|
||||||
|
case 2: return BuildingType::Splitter;
|
||||||
|
case 3: return BuildingType::TunnelEntry; // unified tunnel mode (REQ-BLD-TUNNEL-MODE); 4 unused
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (digit)
|
||||||
|
{
|
||||||
|
case 1: return BuildingType::Miner;
|
||||||
|
case 2: return BuildingType::Smelter;
|
||||||
|
case 3: return BuildingType::Assembler;
|
||||||
|
case 4: return BuildingType::Shipyard;
|
||||||
|
case 5: return BuildingType::SalvageBay;
|
||||||
|
case 6: return BuildingType::ReprocessingPlant;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
QString InputMapper::getBuildHotkeyLabel(BuildingType type)
|
||||||
|
{
|
||||||
|
// Searched out of the binding table above rather than spelled out a second time,
|
||||||
|
// so a badge can never claim a key the handler does not act on. The shift glyph
|
||||||
|
// is written as a code point because the sources are not guaranteed to be read
|
||||||
|
// as UTF-8 by every compiler this builds with. A plain stroke arrow rather than
|
||||||
|
// U+21E7 UPWARDS WHITE ARROW: the standard UI fonts do not all carry the outlined
|
||||||
|
// shift glyph, and the substitute they fall back to is unreadable at badge size.
|
||||||
|
const QChar shiftGlyph(0x2191); // U+2191 UPWARDS ARROW
|
||||||
|
for (int digit = 1; digit <= 9; ++digit)
|
||||||
|
{
|
||||||
|
if (buildHotkeyType(digit, false) == type)
|
||||||
|
{
|
||||||
|
return QString::number(digit);
|
||||||
|
}
|
||||||
|
if (buildHotkeyType(digit, true) == type)
|
||||||
|
{
|
||||||
|
return shiftGlyph + QString::number(digit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InputMapper::handleKeyPress(QKeyEvent* event, const ControlContext& context)
|
||||||
|
{
|
||||||
|
// Auto-repeat says nothing new about which keys are down, and a held action is
|
||||||
|
// already held.
|
||||||
|
if (event->isAutoRepeat()) { return false; }
|
||||||
|
|
||||||
|
// Number-key build-mode hotkeys (REQ-UI-HOTKEYS). nativeVirtualKey gives the
|
||||||
|
// physical digit independent of keyboard layout and Shift (with Shift held, key()
|
||||||
|
// for the number row can arrive as Key_Exclam etc.). VK_1..VK_9 = 0x31..0x39.
|
||||||
|
// Not part of the ControlAction table: these are advertised on the build buttons
|
||||||
|
// rather than in the controls panel, and getBuildHotkeyLabel already reads the
|
||||||
|
// same binding table this does (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
const quint32 virtualKey = event->nativeVirtualKey();
|
||||||
|
if (virtualKey >= 0x31 && virtualKey <= 0x39)
|
||||||
|
{
|
||||||
|
const std::optional<BuildingType> type = buildHotkeyType(
|
||||||
|
static_cast<int>(virtualKey - 0x30),
|
||||||
|
(event->modifiers() & Qt::ShiftModifier) != 0);
|
||||||
|
if (type.has_value())
|
||||||
|
{
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BuildHotkeyPressedEvent>(*type));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Development controls, deliberately outside the table so they are never offered
|
||||||
|
// to the player (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
switch (event->key())
|
||||||
|
{
|
||||||
|
case Qt::Key_F3:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<DebugDrawToggleRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case Qt::Key_F4:
|
||||||
|
EventManager::getInstance()->addEvent(
|
||||||
|
std::make_shared<TracePrintRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else: the key names an action, the action names an event. Which key
|
||||||
|
// is bound to what, and whether it does anything in this situation, are both the
|
||||||
|
// table's business -- this switch only knows what each action means
|
||||||
|
// (REQ-UI-HOTKEYS, REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
switch (resolveKeyAction(event->key(), event->modifiers(), context))
|
||||||
|
{
|
||||||
|
case ControlAction::Move:
|
||||||
|
// A parameter of the action rather than an action of its own: the table binds
|
||||||
|
// both keys to Move and the direction is read off the key here, as the rotation
|
||||||
|
// direction and the build hotkey's digit are.
|
||||||
|
if (event->key() == Qt::Key_A) { m_panLeftHeld = true; }
|
||||||
|
else { m_panRightHeld = true; }
|
||||||
|
updatePanDirection();
|
||||||
|
return true;
|
||||||
|
case ControlAction::GameSpeed:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<SpeedStepRequestedEvent>(event->key() == Qt::Key_W ? +1 : -1));
|
||||||
|
return true;
|
||||||
|
case ControlAction::TogglePause:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<PauseToggleRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::Rotate:
|
||||||
|
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<GhostRotationRequestedEvent>(
|
||||||
|
(event->modifiers() & Qt::ShiftModifier) != 0));
|
||||||
|
return true;
|
||||||
|
case ControlAction::EnterDeconstruct:
|
||||||
|
case ControlAction::ExitMode:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<ModeCancelRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::CopyTemporary:
|
||||||
|
// The BlueprintLibrary owns the selection and blueprint-capture logic; it drives
|
||||||
|
// placement mode from there (REQ-UI-BLUEPRINT-TEMP).
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<TemporaryBlueprintCaptureRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::PasteTemporary:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<TemporaryBlueprintPlaceRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::CreateBlueprint:
|
||||||
|
// Decided by MainWindow, the only widget that can pause the game and dim the
|
||||||
|
// window for a modal.
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintSaveRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::OpenBlueprints:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<BlueprintSelectionRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
case ControlAction::OpenMenu:
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<EscapeMenuRequestedEvent>());
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
// Either nothing is bound to the key here, or what is bound is a mouse gesture
|
||||||
|
// the view handles. Unconsumed, so ordinary Qt shortcuts keep working.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool InputMapper::handleKeyRelease(QKeyEvent* event, const ControlContext& context)
|
||||||
|
{
|
||||||
|
if (event->isAutoRepeat()) { return false; }
|
||||||
|
|
||||||
|
// Only held actions have a release worth acting on. Resolved through the table
|
||||||
|
// rather than matched against A and D directly, so the keys stay rebindable in one
|
||||||
|
// place rather than two.
|
||||||
|
if (resolveKeyAction(event->key(), event->modifiers(), context) != ControlAction::Move)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event->key() == Qt::Key_A) { m_panLeftHeld = false; }
|
||||||
|
else { m_panRightHeld = false; }
|
||||||
|
updatePanDirection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InputMapper::releaseAll()
|
||||||
|
{
|
||||||
|
m_panLeftHeld = false;
|
||||||
|
m_panRightHeld = false;
|
||||||
|
updatePanDirection();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InputMapper::updatePanDirection()
|
||||||
|
{
|
||||||
|
// Holding both keys cancels out rather than favouring one.
|
||||||
|
PanDirection direction = PanDirection::None;
|
||||||
|
if (m_panLeftHeld != m_panRightHeld)
|
||||||
|
{
|
||||||
|
direction = m_panLeftHeld ? PanDirection::Left : PanDirection::Right;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction == m_panDirection) { return; }
|
||||||
|
m_panDirection = direction;
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<PanDirectionChangedEvent>(direction));
|
||||||
|
}
|
||||||
59
src/ui/InputMapper.h
Normal file
59
src/ui/InputMapper.h
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "ControlAction.h"
|
||||||
|
#include "WorldCamera.h"
|
||||||
|
|
||||||
|
class QKeyEvent;
|
||||||
|
|
||||||
|
// Turns raw key events into the game's semantic actions and publishes them
|
||||||
|
// (REQ-UI-HOTKEYS). Widgets react to the action, never to the key, so the two can
|
||||||
|
// be rebound independently later.
|
||||||
|
//
|
||||||
|
// Which key means what is not decided here: the caller hands in a ControlContext and
|
||||||
|
// ControlAction.h resolves the press against it, so this file only knows what each
|
||||||
|
// action means once resolved. That is what keeps the controls panel and the key
|
||||||
|
// handling from drifting apart -- both read the one table (REQ-UI-CONTROLS-ACCURACY).
|
||||||
|
//
|
||||||
|
// Two output shapes, chosen by the nature of the action rather than by taste:
|
||||||
|
//
|
||||||
|
// * Discrete actions — one press, one thing happens — fire an event as they
|
||||||
|
// always have.
|
||||||
|
// * Continuous actions, currently just panning, are held state. They also travel
|
||||||
|
// as events, but level-triggered ones carrying the complete current value (see
|
||||||
|
// PanDirectionChangedEvent), so a receiver never has to reconstruct state from
|
||||||
|
// edges.
|
||||||
|
//
|
||||||
|
// Holding the state here rather than in the widgets is what makes releaseAll()
|
||||||
|
// possible: one call clears every held action at once, which is how a lost key-up
|
||||||
|
// (focus moving to a modal mid-pan) stops being a stuck input.
|
||||||
|
class InputMapper
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// The build hotkey that selects the given building type, spelled for display on
|
||||||
|
// its build button's badge (REQ-UI-BUILD-COST): "1" for a plain digit, "⇧1" for
|
||||||
|
// a Shift+digit, and an empty string for a building type with no build hotkey.
|
||||||
|
// Lives here so the badge and the key handling read the same binding table.
|
||||||
|
static QString getBuildHotkeyLabel(BuildingType type);
|
||||||
|
|
||||||
|
// Both return true when the key was consumed; the caller passes anything else
|
||||||
|
// on to its base class so unrelated shortcuts keep working. `context` is the
|
||||||
|
// player's current situation, which decides what a key does (REQ-UI-CONTROLS-CONTENT).
|
||||||
|
bool handleKeyPress(QKeyEvent* event, const ControlContext& context);
|
||||||
|
bool handleKeyRelease(QKeyEvent* event, const ControlContext& context);
|
||||||
|
|
||||||
|
// Drops all held-key state, publishing the resulting change. Call when the
|
||||||
|
// receiving widget can no longer expect key-up events.
|
||||||
|
void releaseAll();
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Recomputes the pan direction from the held keys and publishes it if it
|
||||||
|
// changed. Holding both keys cancels out.
|
||||||
|
void updatePanDirection();
|
||||||
|
|
||||||
|
bool m_panLeftHeld = false;
|
||||||
|
bool m_panRightHeld = false;
|
||||||
|
PanDirection m_panDirection = PanDirection::None;
|
||||||
|
};
|
||||||
@@ -9,13 +9,16 @@
|
|||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QLineEdit>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QResizeEvent>
|
#include <QResizeEvent>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "BlueprintPanel.h"
|
#include "BlueprintLibrary.h"
|
||||||
#include "BuildButtonGrid.h"
|
#include "BlueprintSelectionDialog.h"
|
||||||
|
#include "BuildButtonBar.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
#include "Command.h"
|
#include "Command.h"
|
||||||
#include "CommandRequestedEvent.h"
|
#include "CommandRequestedEvent.h"
|
||||||
@@ -25,9 +28,11 @@
|
|||||||
#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 "ControlsPanel.h"
|
||||||
#include "ShipLayoutBlueprintSerializer.h"
|
#include "ShipLayoutBlueprintSerializer.h"
|
||||||
#include "ShipLayoutDialog.h"
|
#include "ShipLayoutDialog.h"
|
||||||
|
#include "BuildingIconCache.h"
|
||||||
#include "ItemIconCache.h"
|
#include "ItemIconCache.h"
|
||||||
#include "ModalPauseScope.h"
|
#include "ModalPauseScope.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
@@ -45,50 +50,52 @@ 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);
|
||||||
|
|
||||||
m_sidePanel = new QWidget(this);
|
// Floats over the game world at its bottom center, sized to its buttons
|
||||||
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
// (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
|
||||||
sideLayout->setContentsMargins(1, 1, 1, 1);
|
// building it after the world view puts it above the world and its vignettes,
|
||||||
sideLayout->setSpacing(1);
|
// and before the dim overlay keeps modals dimming it too (REQ-UI-MODAL-DIM).
|
||||||
|
// Its geometry comes from layoutPanels().
|
||||||
|
m_buildButtonBar = new BuildButtonBar(sim, &sim->getConfig(),
|
||||||
|
m_buildingIcons.get(), m_itemIcons.get(),
|
||||||
|
this);
|
||||||
|
|
||||||
// Building icons live alongside the config (a sibling of the config dir), read
|
// The blueprints have no widget of their own: they are saved with Ctrl+C and picked
|
||||||
// from disk at runtime the same way visuals.toml is.
|
// from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window
|
||||||
const std::string iconDir = QDir::cleanPath(
|
// because only it can pause the game and raise the dim overlay. Built after the
|
||||||
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
|
// world view because loading blueprints.toml may put a message box on screen.
|
||||||
|
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
|
||||||
|
// Two facts about blueprints decide what the world view offers the player
|
||||||
|
// (REQ-UI-CONTROLS-CONTENT); the library is built after the view, so it is handed
|
||||||
|
// over here rather than passed to the constructor.
|
||||||
|
m_gameWorldView->setBlueprintLibrary(m_blueprintLibrary.get());
|
||||||
|
|
||||||
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
|
// Floats over the game world at its right edge rather than occupying a column of
|
||||||
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), m_sidePanel);
|
// its own, and hides itself while nothing is selected (REQ-UI-SELECTION-PANEL). Like
|
||||||
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
|
// the build button bar it is a sibling of the world view built after it, which is
|
||||||
|
// what puts it above the world and its vignettes and below the dim overlay. It
|
||||||
|
// brings its own chrome; its geometry comes from layoutPanels().
|
||||||
|
m_selectionPanel = new SelectionPanel(sim, &sim->getConfig(), &m_visuals,
|
||||||
|
m_itemIcons.get(), m_buildingIcons.get(),
|
||||||
|
this);
|
||||||
|
|
||||||
sideLayout->addWidget(m_selectedBuildingPanel, 1);
|
// Floats at the world view's opposite edge from the selection panel and reads the
|
||||||
sideLayout->addWidget(m_buildButtonGrid, 1);
|
// world view for the player's current situation (REQ-UI-CONTROLS-PANEL). Built
|
||||||
sideLayout->addWidget(m_blueprintPanel, 1);
|
// after the view for the same stacking reason as the panels above it.
|
||||||
|
m_controlsPanel = new ControlsPanel(m_gameWorldView, this);
|
||||||
// Draw a thin border around each of the three side-panel sections. The class
|
|
||||||
// scoped selectors keep the border on the panels themselves rather than
|
|
||||||
// cascading onto their child widgets; WA_StyledBackground lets the plain
|
|
||||||
// QWidget subclasses honor the stylesheet box (border/background).
|
|
||||||
for (QWidget* panel : { static_cast<QWidget*>(m_selectedBuildingPanel),
|
|
||||||
static_cast<QWidget*>(m_buildButtonGrid),
|
|
||||||
static_cast<QWidget*>(m_blueprintPanel) })
|
|
||||||
{
|
|
||||||
panel->setAttribute(Qt::WA_StyledBackground, true);
|
|
||||||
}
|
|
||||||
m_sidePanel->setStyleSheet(QStringLiteral(
|
|
||||||
"SelectedBuildingPanel, BuildButtonGrid, BlueprintPanel {"
|
|
||||||
" 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).
|
||||||
@@ -158,12 +165,25 @@ 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
|
||||||
|
// (REQ-UI-BUILD-BAR).
|
||||||
|
m_buildButtonBar->anchorTo(worldRect);
|
||||||
|
// The panel confines itself to what the bar leaves free, so the bar never has to
|
||||||
|
// move for it (REQ-UI-SELECTION-PANEL, REQ-UI-BUILD-BAR).
|
||||||
|
m_selectionPanel->anchorTo(
|
||||||
|
worldRect.adjusted(0, 0, 0, -m_buildButtonBar->getStripHeightPx()));
|
||||||
|
// The opposite corner: bottom-left of the view, sharing the bottom edge with the
|
||||||
|
// bar rather than clearing its whole strip, and rising only if the two would meet.
|
||||||
|
// Anchored after the bar so the geometry it steps around is the current one
|
||||||
|
// (REQ-UI-CONTROLS-PANEL).
|
||||||
|
m_controlsPanel->anchorTo(worldRect, m_buildButtonBar->geometry());
|
||||||
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
|
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +379,47 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
// Ctrl+C has effect only when something player-placeable is selected; otherwise no
|
||||||
|
// dialog opens at all (REQ-UI-BLUEPRINT-CREATE).
|
||||||
|
if (!m_blueprintLibrary->getCanCaptureSelection()) { return; }
|
||||||
|
|
||||||
|
// Both scopes are held across the naming dialog and the selection dialog it hands
|
||||||
|
// over to, so the dim never blinks off and the simulation is not resumed in between
|
||||||
|
// (REQ-UI-MODAL-DIM).
|
||||||
|
ModalPauseScope pause(*m_gameWorldView);
|
||||||
|
ModalDimScope dim(*m_dimOverlay);
|
||||||
|
|
||||||
|
bool ok = false;
|
||||||
|
const QString name = QInputDialog::getText(
|
||||||
|
this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal,
|
||||||
|
QString(), &ok);
|
||||||
|
// Cancel, Escape, or an empty name: no blueprint, and no selection dialog.
|
||||||
|
if (!ok || name.trimmed().isEmpty()) { return; }
|
||||||
|
|
||||||
|
m_blueprintLibrary->saveSelectionAs(name.trimmed());
|
||||||
|
showBlueprintSelectionDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
ModalPauseScope pause(*m_gameWorldView);
|
||||||
|
ModalDimScope dim(*m_dimOverlay);
|
||||||
|
showBlueprintSelectionDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::showBlueprintSelectionDialog()
|
||||||
|
{
|
||||||
|
BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(), this);
|
||||||
|
if (dialog.exec() == QDialog::Accepted && dialog.getChosenIndex().has_value())
|
||||||
|
{
|
||||||
|
// Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD
|
||||||
|
// describes: clicking a card closes the dialog and enters placement mode.
|
||||||
|
m_blueprintLibrary->beginPlacement(*dialog.getChosenIndex());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
||||||
{
|
{
|
||||||
const Tick tick = m_sim->getCurrentTick();
|
const Tick tick = m_sim->getCurrentTick();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "BlueprintSaveRequestedEvent.h"
|
||||||
|
#include "BlueprintSelectionRequestedEvent.h"
|
||||||
#include "BuildingId.h"
|
#include "BuildingId.h"
|
||||||
#include "EscapeMenuRequestedEvent.h"
|
#include "EscapeMenuRequestedEvent.h"
|
||||||
#include "EventHandler.h"
|
#include "EventHandler.h"
|
||||||
@@ -26,9 +28,11 @@ struct ParsedReplay;
|
|||||||
class Simulation;
|
class Simulation;
|
||||||
class GameWorldView;
|
class GameWorldView;
|
||||||
class HeaderBar;
|
class HeaderBar;
|
||||||
class SelectedBuildingPanel;
|
class SelectionPanel;
|
||||||
class BuildButtonGrid;
|
class ControlsPanel;
|
||||||
class BlueprintPanel;
|
class BuildButtonBar;
|
||||||
|
class BlueprintLibrary;
|
||||||
|
class BuildingIconCache;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
class QCloseEvent;
|
class QCloseEvent;
|
||||||
class QResizeEvent;
|
class QResizeEvent;
|
||||||
@@ -39,7 +43,9 @@ class MainWindow : public QWidget,
|
|||||||
WinEvent,
|
WinEvent,
|
||||||
EscapeMenuRequestedEvent,
|
EscapeMenuRequestedEvent,
|
||||||
LayoutDialogRequestedEvent,
|
LayoutDialogRequestedEvent,
|
||||||
RecipeSelectionRequestedEvent>
|
RecipeSelectionRequestedEvent,
|
||||||
|
BlueprintSaveRequestedEvent,
|
||||||
|
BlueprintSelectionRequestedEvent>
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
@@ -59,6 +65,8 @@ private:
|
|||||||
void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override;
|
||||||
|
|
||||||
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
|
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
|
||||||
// by every restart path. On success the reloaded visuals are applied to this
|
// by every restart path. On success the reloaded visuals are applied to this
|
||||||
@@ -71,6 +79,12 @@ private:
|
|||||||
void openShipLayoutDialog(BuildingId shipyardId,
|
void openShipLayoutDialog(BuildingId shipyardId,
|
||||||
const std::string& schematicId,
|
const std::string& schematicId,
|
||||||
const ShipLayoutConfig& currentLayout);
|
const ShipLayoutConfig& currentLayout);
|
||||||
|
|
||||||
|
// Runs the blueprint selection dialog and enters placement mode for whatever the
|
||||||
|
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own:
|
||||||
|
// both callers already hold theirs, which is what keeps the dim continuous when a
|
||||||
|
// confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM).
|
||||||
|
void showBlueprintSelectionDialog();
|
||||||
void layoutPanels();
|
void layoutPanels();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -78,14 +92,19 @@ private:
|
|||||||
VisualsConfig m_visuals;
|
VisualsConfig m_visuals;
|
||||||
Simulation* m_sim;
|
Simulation* m_sim;
|
||||||
// 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 grid, 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;
|
||||||
BuildButtonGrid* m_buildButtonGrid;
|
ControlsPanel* m_controlsPanel;
|
||||||
BlueprintPanel* m_blueprintPanel;
|
BuildButtonBar* m_buildButtonBar;
|
||||||
QWidget* m_sidePanel;
|
// The saved blueprints themselves; they have no widget of their own any more and
|
||||||
|
// are reached through the two modal dialogs (REQ-UI-BLUEPRINT-DIALOG).
|
||||||
|
std::unique_ptr<BlueprintLibrary> m_blueprintLibrary;
|
||||||
ModalDimOverlay* m_dimOverlay = nullptr;
|
ModalDimOverlay* m_dimOverlay = nullptr;
|
||||||
|
|
||||||
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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
267
src/ui/SelectionPanel.cpp
Normal 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
96
src/ui/SelectionPanel.h
Normal 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;
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct OverlayVisuals
|
|||||||
QColor selectionRect;
|
QColor selectionRect;
|
||||||
QColor tileHighlight;
|
QColor tileHighlight;
|
||||||
QColor selectedOutline;
|
QColor selectedOutline;
|
||||||
QColor copyConfig;
|
QColor configTransfer; // blueprint ghost over a transfer target (REQ-UI-BLUEPRINT-TRANSFER)
|
||||||
QColor lockedAsteroid;
|
QColor lockedAsteroid;
|
||||||
QColor modalDim;
|
QColor modalDim;
|
||||||
QColor tunnelPreview; // tunnel connection preview highlight (REQ-BLD-TUNNEL-MODE)
|
QColor tunnelPreview; // tunnel connection preview highlight (REQ-BLD-TUNNEL-MODE)
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ VisualsConfig VisualsLoader::load(const std::string& path)
|
|||||||
cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect");
|
cfg.overlays.selectionRect = parseColor(requireString(ov, "selection_rect", "overlays"), "overlays.selection_rect");
|
||||||
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
|
cfg.overlays.tileHighlight = parseColor(requireString(ov, "tile_highlight", "overlays"), "overlays.tile_highlight");
|
||||||
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
|
cfg.overlays.selectedOutline = parseColor(requireString(ov, "selected_outline", "overlays"), "overlays.selected_outline");
|
||||||
cfg.overlays.copyConfig = parseColor(requireString(ov, "copy_config", "overlays"), "overlays.copy_config");
|
cfg.overlays.configTransfer = parseColor(requireString(ov, "config_transfer", "overlays"), "overlays.config_transfer");
|
||||||
cfg.overlays.lockedAsteroid = parseColor(requireString(ov, "locked_asteroid", "overlays"), "overlays.locked_asteroid");
|
cfg.overlays.lockedAsteroid = parseColor(requireString(ov, "locked_asteroid", "overlays"), "overlays.locked_asteroid");
|
||||||
cfg.overlays.modalDim = parseColor(requireString(ov, "modal_dim", "overlays"), "overlays.modal_dim");
|
cfg.overlays.modalDim = parseColor(requireString(ov, "modal_dim", "overlays"), "overlays.modal_dim");
|
||||||
cfg.overlays.tunnelPreview = parseColor(requireString(ov, "tunnel_preview", "overlays"), "overlays.tunnel_preview");
|
cfg.overlays.tunnelPreview = parseColor(requireString(ov, "tunnel_preview", "overlays"), "overlays.tunnel_preview");
|
||||||
|
|||||||
101
src/ui/WorldPrimitives.cpp
Normal file
101
src/ui/WorldPrimitives.cpp
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#include "WorldPrimitives.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPen>
|
||||||
|
#include <QPolygonF>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Ship triangle proportions, as fractions of a tile: the nose reaches further
|
||||||
|
// than the tail corners spread, so the facing direction reads at a glance.
|
||||||
|
const float kShipForwardTileFactor = 0.45f;
|
||||||
|
const float kShipSideTileFactor = 0.25f;
|
||||||
|
|
||||||
|
const float kDebrisRadiusTileFactor = 0.2f;
|
||||||
|
|
||||||
|
// Health bar height as a fraction of a tile.
|
||||||
|
const qreal kHealthBarHeightTileFactor = 0.12;
|
||||||
|
|
||||||
|
const QColor kHealthBarTrack (60, 60, 60);
|
||||||
|
const QColor kHealthBarEnemy (200, 60, 60);
|
||||||
|
const QColor kHealthBarPlayer (60, 200, 60);
|
||||||
|
|
||||||
|
const QColor kDebrisFill (128, 110, 90);
|
||||||
|
const QColor kDebrisOutline(50, 40, 30);
|
||||||
|
|
||||||
|
// Sensor circles are drawn faint so a crowded field stays readable.
|
||||||
|
const int kSensorRangeAlpha = 77;
|
||||||
|
}
|
||||||
|
|
||||||
|
float getShipForwardExtentPx(const WorldCoordinates& coordinates)
|
||||||
|
{
|
||||||
|
return coordinates.getTilePx() * kShipForwardTileFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
float getDebrisRadiusPx(const WorldCoordinates& coordinates)
|
||||||
|
{
|
||||||
|
return coordinates.getTilePx() * kDebrisRadiusTileFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawShipBody(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
QPointF center, float facing_radians,
|
||||||
|
const QColor& fill, const QColor& outline)
|
||||||
|
{
|
||||||
|
const QVector2D direction(std::cos(facing_radians), std::sin(facing_radians));
|
||||||
|
const QVector2D perpendicular(-direction.y(), direction.x());
|
||||||
|
|
||||||
|
const float forward = getShipForwardExtentPx(coordinates);
|
||||||
|
const float side = coordinates.getTilePx() * kShipSideTileFactor;
|
||||||
|
|
||||||
|
QPolygonF triangle;
|
||||||
|
triangle
|
||||||
|
<< QPointF(center.x() + static_cast<qreal>(direction.x() * forward),
|
||||||
|
center.y() + static_cast<qreal>(direction.y() * forward))
|
||||||
|
<< QPointF(center.x() + static_cast<qreal>(perpendicular.x() * side - direction.x() * side),
|
||||||
|
center.y() + static_cast<qreal>(perpendicular.y() * side - direction.y() * side))
|
||||||
|
<< QPointF(center.x() + static_cast<qreal>(-perpendicular.x() * side - direction.x() * side),
|
||||||
|
center.y() + static_cast<qreal>(-perpendicular.y() * side - direction.y() * side));
|
||||||
|
|
||||||
|
painter.setPen(QPen(outline, 1));
|
||||||
|
painter.setBrush(fill);
|
||||||
|
painter.drawPolygon(triangle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawHealthBar(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
qreal left, qreal top, qreal width, float fraction, bool isEnemy)
|
||||||
|
{
|
||||||
|
const qreal height = static_cast<qreal>(coordinates.getTilePx())
|
||||||
|
* kHealthBarHeightTileFactor;
|
||||||
|
const float clamped = std::max(0.0f, fraction);
|
||||||
|
|
||||||
|
painter.fillRect(QRectF(left, top, width, height), kHealthBarTrack);
|
||||||
|
painter.fillRect(QRectF(left, top, width * static_cast<qreal>(clamped), height),
|
||||||
|
isEnemy ? kHealthBarEnemy : kHealthBarPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
QPointF center)
|
||||||
|
{
|
||||||
|
const qreal radius = static_cast<qreal>(getDebrisRadiusPx(coordinates));
|
||||||
|
painter.setBrush(kDebrisFill);
|
||||||
|
painter.setPen(QPen(kDebrisOutline, 1));
|
||||||
|
painter.drawEllipse(center, radius, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawSensorRange(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
QPointF center, float range_tiles, const QColor& shipOutline)
|
||||||
|
{
|
||||||
|
const qreal radius = static_cast<qreal>(range_tiles)
|
||||||
|
* static_cast<qreal>(coordinates.getTilePx());
|
||||||
|
QColor circleColor = shipOutline;
|
||||||
|
circleColor.setAlpha(kSensorRangeAlpha);
|
||||||
|
|
||||||
|
painter.setPen(QPen(circleColor, 1));
|
||||||
|
painter.setBrush(Qt::NoBrush);
|
||||||
|
painter.drawEllipse(center, radius, radius);
|
||||||
|
}
|
||||||
50
src/ui/WorldPrimitives.h
Normal file
50
src/ui/WorldPrimitives.h
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QPointF>
|
||||||
|
|
||||||
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
|
class QPainter;
|
||||||
|
|
||||||
|
// The handful of world-space shapes the game view and the balancing tool's arena
|
||||||
|
// view draw identically: a ship, its health bar, a piece of debris, a sensor range.
|
||||||
|
//
|
||||||
|
// Shared because the arena exists to eyeball combat, which only works while a ship
|
||||||
|
// there looks like a ship in the game — if these drift, the balancing tool quietly
|
||||||
|
// stops representing what it is measuring. Deliberately kept to shapes that are
|
||||||
|
// genuinely the same in both: the two views differ on selection highlights, beams
|
||||||
|
// and target lines, and those stay with each view.
|
||||||
|
//
|
||||||
|
// Free functions taking explicit values, with no state and no simulation: each
|
||||||
|
// view keeps its own iteration and layer order. Sizes derive from the tile size so
|
||||||
|
// everything scales with the view (REQ-GW-TILE-SIZE).
|
||||||
|
|
||||||
|
// Distance from a ship's centre to its nose, in pixels. The selection ring and the
|
||||||
|
// health bar are positioned from this, so callers need it whether or not they are
|
||||||
|
// the ones drawing the body.
|
||||||
|
float getShipForwardExtentPx(const WorldCoordinates& coordinates);
|
||||||
|
|
||||||
|
// Radius of a piece of debris, in pixels. Shared so the selection ring around
|
||||||
|
// debris cannot drift from the debris it is meant to circle.
|
||||||
|
float getDebrisRadiusPx(const WorldCoordinates& coordinates);
|
||||||
|
|
||||||
|
// Draws a ship as a triangle at `center` (widget space), pointing along
|
||||||
|
// `facing_radians`.
|
||||||
|
void drawShipBody(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
QPointF center, float facing_radians,
|
||||||
|
const QColor& fill, const QColor& outline);
|
||||||
|
|
||||||
|
// Draws a health bar: a dark track with `fraction` of it filled, red for an enemy
|
||||||
|
// and green for the player. A negative fraction is clamped to empty.
|
||||||
|
void drawHealthBar(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
qreal left, qreal top, qreal width, float fraction, bool isEnemy);
|
||||||
|
|
||||||
|
// Draws a piece of debris as a small brown circle at `center` (widget space).
|
||||||
|
void drawDebrisMarker(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
QPointF center);
|
||||||
|
|
||||||
|
// Draws a ship's sensor range as a faint circle in the ship's own outline colour,
|
||||||
|
// so overlapping ranges stay distinguishable.
|
||||||
|
void drawSensorRange(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
QPointF center, float range_tiles, const QColor& shipOutline);
|
||||||
1159
src/ui/WorldRenderer.cpp
Normal file
1159
src/ui/WorldRenderer.cpp
Normal file
File diff suppressed because it is too large
Load Diff
169
src/ui/WorldRenderer.h
Normal file
169
src/ui/WorldRenderer.h
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QRectF>
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "BeamFiredEvent.h"
|
||||||
|
#include "BuildModeController.h"
|
||||||
|
#include "BuildingId.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
#include "SelectionController.h"
|
||||||
|
#include "VisualsConfig.h"
|
||||||
|
#include "WorldCoordinates.h"
|
||||||
|
|
||||||
|
class ItemIconCache;
|
||||||
|
class Simulation;
|
||||||
|
class QPainter;
|
||||||
|
class QSvgRenderer;
|
||||||
|
|
||||||
|
// A beam still being drawn. Lifetime is counted in game ticks so beams freeze with
|
||||||
|
// the simulation when it is paused or slowed (REQ-SHP-FIRING-BEAM); the view owns
|
||||||
|
// the ageing, the renderer only draws what it is given.
|
||||||
|
struct ActiveBeam
|
||||||
|
{
|
||||||
|
BeamFiredEvent event;
|
||||||
|
QVector2D targetOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
// How a placement ghost is colored. Normal shows the building type's own colors
|
||||||
|
// (REQ-BLD-GHOST); the other two override them (REQ-BLD-PLACE-VALID,
|
||||||
|
// REQ-UI-BLUEPRINT-TRANSFER).
|
||||||
|
enum class GhostTint
|
||||||
|
{
|
||||||
|
Normal,
|
||||||
|
Invalid,
|
||||||
|
Transfer
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything the renderer draws that the simulation does not know about: what the
|
||||||
|
// player has selected, which build mode is active, and the other transient bits of
|
||||||
|
// interaction state the view owns. Assembled fresh each frame and passed by
|
||||||
|
// reference, so the renderer holds no copy that could go stale.
|
||||||
|
struct WorldRenderFrame
|
||||||
|
{
|
||||||
|
const SelectionController& selection;
|
||||||
|
const BuildModeController& buildMode;
|
||||||
|
const std::vector<ActiveBeam>& beams;
|
||||||
|
bool isBoxSelecting;
|
||||||
|
QPoint boxStartTile;
|
||||||
|
QPoint boxCurrentTile;
|
||||||
|
bool isDebugDrawEnabled;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draws the game world: terrain, buildings, items, ships, effects and the build
|
||||||
|
// overlays, in back-to-front order (see the Layer Order section of
|
||||||
|
// docs/architecture.md).
|
||||||
|
//
|
||||||
|
// Reads the simulation and never writes it, and knows nothing about input — the
|
||||||
|
// view resolves clicks and owns the interaction state, and hands the parts the
|
||||||
|
// renderer needs over in a WorldRenderFrame.
|
||||||
|
//
|
||||||
|
// Everything here is positioned in tiles. Screen-anchored chrome — the pause and
|
||||||
|
// deconstruct vignettes, the replay overlay, the debug stats panel — stays with
|
||||||
|
// the view, which is why nothing in this class draws translatable text.
|
||||||
|
class WorldRenderer
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// `itemIcons` is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); not
|
||||||
|
// owned, must outlive this renderer. `configDir` is used once, to load the
|
||||||
|
// per-building world icons.
|
||||||
|
WorldRenderer(Simulation& sim, const VisualsConfig& visuals,
|
||||||
|
ItemIconCache* itemIcons, const std::string& configDir);
|
||||||
|
~WorldRenderer();
|
||||||
|
|
||||||
|
void render(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawSelectionHighlights(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawPortItems(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawStations(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawBeltItems(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawShips(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawSelectedTunnelConnections(QPainter& painter,
|
||||||
|
const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
void drawOverlays(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
const WorldRenderFrame& frame);
|
||||||
|
// Draws a single item centered at widget-space `center`, spanning `halfPx` in
|
||||||
|
// each direction (a half-tile). Uses the item's icon when one exists
|
||||||
|
// (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from
|
||||||
|
// visuals.toml. Shared by drawBeltItems and drawPortItems.
|
||||||
|
void drawWorldItem(QPainter& painter, const std::string& itemId,
|
||||||
|
QPointF center, float halfPx);
|
||||||
|
void drawPortGlyph(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
QPoint tile, Rotation direction, const QColor& color,
|
||||||
|
bool centered);
|
||||||
|
void drawBuildingGhost(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
BuildingType type, QPoint anchorTile, Rotation rotation,
|
||||||
|
GhostTint tint, bool showPortTargetGlyphs);
|
||||||
|
|
||||||
|
// Loads the per-building world icons (REQ-UI-WORLD-ICON) from
|
||||||
|
// <configDir>/../icons/buildings once at construction. Only the building
|
||||||
|
// types with a world icon are loaded (production buildings, HQ, stations);
|
||||||
|
// belts, splitters, and tunnels are deliberately excluded so their
|
||||||
|
// orientation stays readable. The SVG's chip background is stripped; the
|
||||||
|
// glyph is pre-rendered in both white and dark ink for auto-contrast.
|
||||||
|
void loadBuildingIcons(const std::string& configDir);
|
||||||
|
// Draws a building's world icon glyph centered in box, choosing the white or
|
||||||
|
// dark pre-rendered variant by fill luminance so it stays legible. Returns
|
||||||
|
// false if the type has no world icon (caller falls back to the text glyph).
|
||||||
|
bool drawBuildingIcon(QPainter& painter, const WorldCoordinates& coordinates,
|
||||||
|
BuildingType type, const QRectF& box,
|
||||||
|
const QColor& fill) const;
|
||||||
|
|
||||||
|
// Widget-space rectangle covering a building or construction site's footprint,
|
||||||
|
// or nullopt if the id resolves to neither. Used by the selection highlight.
|
||||||
|
std::optional<QRectF> footprintWidgetRect(const WorldCoordinates& coordinates,
|
||||||
|
BuildingId id) const;
|
||||||
|
|
||||||
|
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||||
|
|
||||||
|
// Non-const only because EntityAdmin's component accessors are; the renderer
|
||||||
|
// reads the simulation and never writes it.
|
||||||
|
Simulation& m_sim;
|
||||||
|
const VisualsConfig& m_visuals;
|
||||||
|
|
||||||
|
// Per-item icon cache (REQ-UI-ITEM-ICON), shared window-wide and owned by
|
||||||
|
// MainWindow. Shared draw path for belt and port items; pixmaps are cached
|
||||||
|
// per target size.
|
||||||
|
ItemIconCache* m_itemIcons;
|
||||||
|
|
||||||
|
// World icon glyph renderers per building type (REQ-UI-WORLD-ICON), in a
|
||||||
|
// white and a dark variant so drawBuildingIcon can auto-contrast against the
|
||||||
|
// building's fill. Rendered as vector at the view scale each draw so they
|
||||||
|
// stay crisp. Populated once by loadBuildingIcons().
|
||||||
|
struct BuildingIconRenderers
|
||||||
|
{
|
||||||
|
std::unique_ptr<QSvgRenderer> white;
|
||||||
|
std::unique_ptr<QSvgRenderer> dark;
|
||||||
|
};
|
||||||
|
std::map<BuildingType, BuildingIconRenderers> m_buildingIcons;
|
||||||
|
};
|
||||||
44
src/ui/selection/AutoProductionContent.cpp
Normal file
44
src/ui/selection/AutoProductionContent.cpp
Normal 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;
|
||||||
|
}
|
||||||
20
src/ui/selection/AutoProductionContent.h
Normal file
20
src/ui/selection/AutoProductionContent.h
Normal 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;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user