Compare commits
55 Commits
a86cbd3fbd
...
refactorin
| Author | SHA1 | Date | |
|---|---|---|---|
| 590bca458c | |||
| 59067a9c49 | |||
| 72d85d681c | |||
| a86ba3428a | |||
| 56b7248ac7 | |||
| 009f8c6d14 | |||
| a90218f5c0 | |||
| 7ce0751c60 | |||
| b3d6264ed3 | |||
| ade716edf2 | |||
| 3272431353 | |||
| d1b688f45e | |||
| df7f60c898 | |||
| 58e173ad5b | |||
| bb50f527d6 | |||
| 7540c21d5c | |||
| 2522a8c974 | |||
| e39b81eb22 | |||
| 71d0dad3f2 | |||
| fda88fe75c | |||
| d713257fb5 | |||
| 0029236135 | |||
| 622447c45b | |||
| a34d66f548 | |||
| cb5572ffdd | |||
| 10ba226af7 | |||
| 0a3288d1d1 | |||
| 2c9433cea6 | |||
| 21eb6ad096 | |||
| bf579bb76e | |||
| 6bde69dc33 | |||
| bf99cd0694 | |||
| 6f107e3479 | |||
| 8f42519911 | |||
| bb7b90f9ea | |||
| e702c01005 | |||
| b1720dc2b3 | |||
| 099f0b55fc | |||
| b133a21914 | |||
| 69848eb9f8 | |||
| 993325d97c | |||
| 75f306f650 | |||
| 0eb9c97e5d | |||
| 28d0416458 | |||
| 59fde8dbbc | |||
| 5355f9f77d | |||
| f678dab387 | |||
| ebee62166d | |||
| 3c549a160c | |||
| dc58f6ea32 | |||
| 5bd804601c | |||
| 92a4f02cef | |||
| 84d32b6c16 | |||
| d31ff68ab7 | |||
| b722955f7e |
@@ -5,10 +5,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Interaction
|
||||
|
||||
* 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
|
||||
|
||||
@@ -35,15 +31,6 @@ keep the citation accurate.
|
||||
## Coding Guidelines
|
||||
|
||||
* 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:
|
||||
* **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
|
||||
@@ -60,13 +47,6 @@ keep the citation accurate.
|
||||
* 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()")
|
||||
* 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
|
||||
|
||||
@@ -103,15 +83,6 @@ output directories and copies the Qt DLLs.
|
||||
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
|
||||
`--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
|
||||
|
||||
Catch2, single executable, links `lib` only — no QApplication, no display.
|
||||
|
||||
@@ -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()`.
|
||||
|
||||
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonBar` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonBar`).
|
||||
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonGrid` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonGrid`).
|
||||
|
||||
### Reading Simulation State
|
||||
|
||||
@@ -124,7 +124,7 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
|
||||
Three product targets plus tests:
|
||||
|
||||
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
|
||||
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button bar. Depends on `lib` and on Qt's OpenGL widgets module.
|
||||
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button grid. Depends on `lib` and on Qt's OpenGL widgets module.
|
||||
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
|
||||
- `tests/` — Catch2 tests. Links only against `lib`.
|
||||
|
||||
@@ -329,11 +329,7 @@ Buildings and the belt subsystem stay outside any entity model regardless of wha
|
||||
|
||||
## Rendering
|
||||
|
||||
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 copy-settings feedback, 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.
|
||||
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.
|
||||
|
||||
### Render Loop
|
||||
|
||||
@@ -361,11 +357,9 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
|
||||
|
||||
### Coordinates and Scrolling
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- `GameWorldView` holds a continuous `scrollXTiles` (float). A / D input pans this smoothly (REQ-UI-SCROLL).
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
### Culling
|
||||
|
||||
@@ -375,8 +369,6 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
Sketch of `visuals.toml`:
|
||||
|
||||
@@ -135,7 +135,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
||||
- **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.
|
||||
- **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 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.
|
||||
- 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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -401,7 +401,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-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-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-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
|
||||
|
||||
@@ -433,7 +433,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
||||
|
||||
### Layout
|
||||
|
||||
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) holding the selected building panel. The build button bar (REQ-UI-BUILD-BAR) is not part of either column — it floats over the game world at its bottom center. Blueprints have no permanent screen real estate; they are reached through modal dialogs (REQ-UI-BLUEPRINT-DIALOG):
|
||||
The screen is 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:
|
||||
|
||||
```
|
||||
+--------------------------------------+--------------+
|
||||
@@ -441,12 +441,14 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
+--------------------------------------+ Selected |
|
||||
| | Building |
|
||||
| | Panel |
|
||||
| Game World | |
|
||||
| | |
|
||||
| | |
|
||||
| +------------------+ | |
|
||||
| | Build Button Bar | | |
|
||||
+--------+------------------+----------+--------------+
|
||||
| +--------------+
|
||||
| Game World | Build |
|
||||
| | Button |
|
||||
| | Grid |
|
||||
| +--------------+
|
||||
| | Blueprint |
|
||||
| | Panel |
|
||||
+--------------------------------------+--------------+
|
||||
(75% width) (25% width)
|
||||
```
|
||||
|
||||
@@ -460,8 +462,8 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- REQ-UI-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
|
||||
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
|
||||
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
|
||||
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It holds a single panel filling that full height: the selected building panel. The build buttons are not part of this column; they float over the game world (REQ-UI-BUILD-BAR). Blueprints are not part of this column either; they are reached through the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG).
|
||||
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP), the blueprint save dialog (REQ-UI-BLUEPRINT-CREATE), and the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The same applies when one modal hands directly off to another — the blueprint save dialog opening the blueprint selection dialog on confirm (REQ-UI-BLUEPRINT-CREATE): the dim persists across the handoff rather than flickering off and back on, and the simulation is not resumed in between. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
|
||||
- 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-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.
|
||||
|
||||
### Game World
|
||||
|
||||
@@ -496,9 +498,7 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- **Q** — context-sensitive. If a build mode is active (builder mode or blueprint placement mode), pressing Q exits it. Otherwise, pressing Q toggles deconstruct mode: it enters deconstruct mode if inactive, or exits deconstruct mode if already active. (See also REQ-UI-DECONSTRUCT-BUTTON for the equivalent button.)
|
||||
- **R / Shift+R** — in builder mode, rotate the ghost counter-clockwise / clockwise (REQ-BLD-ROTATE).
|
||||
- **T** — create a temporary blueprint from the current selection and enter its placement mode (REQ-UI-BLUEPRINT-TEMP).
|
||||
- **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).
|
||||
- **Escape** — opens the escape menu (REQ-UI-GAME-MENU).
|
||||
- **Build mode selection** — pressing a build hotkey activates builder mode for the corresponding building type, equivalent to clicking its build button (REQ-BLD-BUILDER-MODE):
|
||||
- **1** — Belt, **2** — Splitter, **3** — Tunnel (the unified tunnel build mode, REQ-BLD-TUNNEL-MODE). Hotkey 4 is unused.
|
||||
- **Shift+1** — Miner, **Shift+2** — Smelter, **Shift+3** — Assembler, **Shift+4** — Shipyard, **Shift+5** — Salvage Bay, **Shift+6** — Reprocessing Plant.
|
||||
@@ -529,7 +529,7 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows: building name, current recipe or schematic selection, input buffer contents, and output buffer contents. Buffer counts are displayed as `a/b` where `a` is the current item count and `b` is the per-cycle amount (items consumed per run for inputs; items produced per run for outputs). For a selected construction site, the recipe/schematic selection (and, for a shipyard, the layout preview and "Configure" button) are shown but the buffer rows are omitted (REQ-BLD-SITE-CONFIG).
|
||||
- REQ-UI-PRODUCTION-PROGRESS: For buildings that produce items or ships (miner, smelter, assembler, reprocessing plant, shipyard), the selected building panel also shows: (a) the cycle time of the currently selected recipe or schematic in seconds, and (b) the completion percentage of the active production cycle as an integer (e.g. `42%`), or the text `idle` when no production cycle is active. When no recipe or schematic is selected, neither the cycle time nor the progress indicator is shown.
|
||||
- REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
|
||||
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-CARD). Construction sites count at their building type's full placement cost regardless of construction progress.
|
||||
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button 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-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
|
||||
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
||||
- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip:
|
||||
@@ -553,64 +553,32 @@ The screen is divided into two columns: a main column (75% width) containing the
|
||||
- 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).
|
||||
|
||||
### Build Button Bar
|
||||
### Build Button Grid
|
||||
|
||||
- REQ-UI-BUILD-BAR: All placeable building types are shown as a **single horizontal row** of buttons with no grouping and no wrapping, inside a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), horizontally centered and anchored at the bottom edge with a small margin. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. The bar is sized to its buttons and re-centers whenever the set of shown buttons changes (REQ-LOCK-BUILDING) or the view is resized.
|
||||
- **Overlay behavior.** The bar occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected (the world is not inset for the bar). The bar is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full 100-pixel bottom band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the bar.
|
||||
- **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-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-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".
|
||||
- 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.
|
||||
- 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.
|
||||
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
||||
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
||||
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button 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.
|
||||
|
||||
### Blueprints
|
||||
### Blueprint Panel
|
||||
|
||||
Blueprints occupy no permanent screen space. They are saved with **Ctrl+C** from the current selection (REQ-UI-BLUEPRINT-CREATE) and picked for placement from the blueprint selection dialog, opened with **Ctrl+V** (REQ-UI-BLUEPRINT-DIALOG). There is no blueprint panel in the side panel column (REQ-UI-PANEL-COLUMN). (The ship layout blueprint panel of the layout configuration dialog, REQ-MOD-UI-BLUEPRINT-PANEL, is a separate feature and is unaffected.)
|
||||
- REQ-UI-BLUEPRINT-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).
|
||||
|
||||
- REQ-UI-BLUEPRINT-CREATE: Pressing **Ctrl+C** (REQ-UI-HOTKEYS) opens the modal **blueprint save dialog**, which pauses the simulation and dims the game window (REQ-UI-MODAL-DIM). It has effect only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition, and pressing Ctrl+C with an empty selection or a selection of only non-player-placeable buildings does nothing (no dialog opens). A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). The dialog prompts the player to enter a name and has Confirm and Cancel buttons. Clicking Cancel — or pressing Escape, or closing the dialog — closes it with no effect and does not open the blueprint selection dialog. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, appends it to the blueprint list, closes the save dialog, and immediately opens the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG) showing the new blueprint among the others.
|
||||
- REQ-UI-BLUEPRINT-CREATE: 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-BLUEPRINT-DIALOG: The **blueprint selection dialog** is the only place saved blueprints are shown. It is opened by pressing **Ctrl+V** (REQ-UI-HOTKEYS) and by confirming a save (REQ-UI-BLUEPRINT-CREATE). It is modal, pauses the simulation, and dims the game window (REQ-UI-MODAL-DIM). The dialog has a fixed size and consists of:
|
||||
- A **title bar** reading `Blueprints`, followed by a small dimmed **hotkey badge** reading `Ctrl+V` — the same "learn the shortcut from the widget" device as the build button badges (REQ-UI-BUILD-COST) — and, at the far right, a **close ("×") button**.
|
||||
- Below it, a **scrollable two-column grid of blueprint cards** (REQ-UI-BLUEPRINT-CARD), one per saved blueprint, filling the grid left to right and top to bottom in creation order. The column count is fixed at two; the grid scrolls vertically when the cards do not fit, and does not scroll horizontally.
|
||||
- When no blueprints are saved, the dialog still opens and shows an empty-state message in place of the grid, telling the player that blueprints are created with Ctrl+C from a selection of buildings.
|
||||
|
||||
Clicking the close button, pressing Escape, or closing the dialog through the window manager closes it with no other effect: the current selection, build mode, and blueprint list are unchanged, and the simulation speed is restored to what it was before the dialog was opened. While the dialog is open, Escape closes it rather than opening the escape menu (REQ-UI-GAME-MENU).
|
||||
|
||||
```
|
||||
+------------------------------------------------------+
|
||||
| Blueprints [Ctrl+V] [x] |
|
||||
+------------------------------------------------------+
|
||||
| +---------------------+ +---------------------+ |^| |
|
||||
| | Smelter array | | Gear cell | | | |
|
||||
| | 4 Smelters, 1 Miner | | 2 Assemblers, ... | | | |
|
||||
| | 528 [blk] (x) | | 460 [blk] (x) | | | |
|
||||
| +---------------------+ +---------------------+ | | |
|
||||
| +---------------------+ | | |
|
||||
| | Defence line | |_v_|
|
||||
+------------------------------------------------------+
|
||||
```
|
||||
|
||||
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint selection dialog (REQ-UI-BLUEPRINT-DIALOG), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
|
||||
- REQ-UI-BLUEPRINT-TEMP: Pressing the **T** key (REQ-UI-HOTKEYS) creates a **temporary blueprint** from the current selection and immediately enters blueprint placement mode for it, without opening the naming dialog. It has effect only when at least one player-placeable building is currently selected — the same condition as REQ-UI-BLUEPRINT-CREATE; pressing T with an empty selection, or a selection containing only non-player-placeable buildings (HQ, defence stations), does nothing. Entering this mode replaces any currently active build, blueprint placement, or deconstruct mode. The temporary blueprint is captured exactly as a saved blueprint (REQ-UI-BLUEPRINT-STORAGE), silently excluding any non-player-placeable buildings from the selection, but it is never named, never shown in the blueprint panel (REQ-UI-BLUEPRINT-PANEL), and never persisted to `blueprints.toml` (REQ-UI-BLUEPRINT-SAVE). Placement behaves identically to a saved blueprint's placement mode (REQ-UI-BLUEPRINT-MODE, REQ-UI-BLUEPRINT-PLACE): a ghost is rendered per building, R / Shift+R rotate the entire constellation, placement follows the same per-building validity and total-cost rules, and after a successful placement the mode stays active so the blueprint can be placed again. Right-clicking in the game world exits placement mode, at which point the temporary blueprint is discarded.
|
||||
|
||||
- REQ-UI-BLUEPRINT-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-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-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.
|
||||
|
||||
A card shows no preview of the blueprint's layout.
|
||||
|
||||
Clicking anywhere on an enabled card other than its delete icon closes the dialog and enters blueprint placement mode for that blueprint (REQ-UI-BLUEPRINT-MODE). A card is disabled when the player cannot currently afford its total cost; a disabled card is rendered dimmed and clicking it does nothing (consistent with REQ-UI-BUILD-DISABLED), and neither closes the dialog nor enters placement mode. The delete icon is always enabled regardless of whether the player can afford the blueprint.
|
||||
|
||||
- REQ-UI-BLUEPRINT-MODE: In blueprint placement mode a ghost is rendered for every building in the blueprint (excluding any of a currently locked building type, REQ-LOCK-BUILDING, which is omitted entirely per REQ-LOCK-UI-BLUEPRINT) at the position determined by its stored tile offset from the bounding-box center, which is anchored to the tile under the cursor. Each ghost is rendered individually as valid or invalid, applying REQ-BLD-PLACE-VALID conditions (a) and (b) per building (the other ghosts in the same blueprint do not count as existing buildings for the overlap check). A valid ghost uses its building type's semi-transparent per-building coloring (REQ-BLD-GHOST); an invalid ghost uses the distinct "invalid" color, as in single-building builder mode. Pressing R / Shift+R rotates the entire constellation 90° counter-clockwise / clockwise: each building's tile offset is rotated around the bounding-box center and each building's own rotation is updated, consistent with REQ-BLD-ROTATE. Blueprint placement mode is exited by right-clicking in the game world. Opening the blueprint selection dialog while placement mode is active (REQ-UI-BLUEPRINT-DIALOG) leaves the mode active — closing the dialog without picking a card returns to it unchanged — while clicking a card exits the current mode and enters blueprint placement mode for the newly picked blueprint.
|
||||
- REQ-UI-BLUEPRINT-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.
|
||||
|
||||
- REQ-UI-BLUEPRINT-PLACE: Buildings of a currently locked building type (REQ-LOCK-BUILDING) are first excluded from the blueprint for this placement, per REQ-LOCK-UI-BLUEPRINT — they are not ghosted, not validity-checked, not placed, and their cost is excluded from the total. Left-clicking in blueprint placement mode then places the (remaining) blueprint if (a) every building in the constellation satisfies REQ-BLD-PLACE-VALID conditions (a) and (b) at its resolved tile, and (b) the player has enough building blocks to afford the total cost. If both conditions are met, a construction site is added to the build queue for each building in the blueprint and the full total cost is deducted from the global building blocks stock in one transaction. If a recipe ID is stored for a building, it is applied to the construction site immediately. If a schematic ID is stored, it is applied only if that schematic is currently unlocked; if it is not unlocked, the shipyard's schematic is left unset. If splitter output filters are stored, they are applied to the construction site immediately and carry over when it finishes building (REQ-BLD-SITE-CONFIG). Locked recipe IDs and splitter filter entries for locked item types are handled on placement per REQ-LOCK-UI-BLUEPRINT. After a successful placement the game remains in blueprint placement mode, allowing the player to place the same blueprint again immediately.
|
||||
|
||||
- REQ-UI-BLUEPRINT-DELETE: Clicking the delete icon ("×") on a blueprint 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-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-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,3 +1,6 @@
|
||||
|
||||
|
||||
|
||||
set(TARGET_BASE_NAME "${PRODUCT_NAME}")
|
||||
|
||||
set(TARGET_APP_NAME "${TARGET_BASE_NAME}")
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "DebrisComponent.h"
|
||||
#include "WorldPrimitives.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -177,36 +176,55 @@ void ArenaView::paintGL()
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing, false);
|
||||
|
||||
// One transform snapshot for the whole frame; every draw below reads the
|
||||
// viewport through it.
|
||||
const WorldCoordinates coordinates = getCoordinates();
|
||||
|
||||
drawTiles(painter, coordinates);
|
||||
drawBuildings(painter, coordinates);
|
||||
drawStations(painter, coordinates);
|
||||
drawDebris(painter, coordinates);
|
||||
drawTiles(painter);
|
||||
drawBuildings(painter);
|
||||
drawStations(painter);
|
||||
drawDebris(painter);
|
||||
if (m_debugDraw)
|
||||
{
|
||||
drawDebugSensorRanges(painter, coordinates);
|
||||
drawDebugTargetLines(painter, coordinates);
|
||||
drawDebugSensorRanges(painter);
|
||||
drawDebugTargetLines(painter);
|
||||
}
|
||||
drawShips(painter, coordinates);
|
||||
drawBeams(painter, coordinates);
|
||||
drawShips(painter);
|
||||
drawBeams(painter);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinate helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
WorldCoordinates ArenaView::getCoordinates() const
|
||||
float ArenaView::getTilePx() 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 int totalWidth = ac.playerBufferWidth_tiles
|
||||
+ ac.contestZoneWidth_tiles
|
||||
+ ac.enemyBufferWidth_tiles;
|
||||
return WorldCoordinates::fitToWorld(size(), totalWidth, ac.heightTiles);
|
||||
const int totalHeight = 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
|
||||
@@ -218,11 +236,19 @@ std::optional<QVector2D> ArenaView::entityPosition(entt::entity entity) const
|
||||
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)
|
||||
{
|
||||
if (event->button() == Qt::LeftButton)
|
||||
{
|
||||
const QVector2D worldPos = getCoordinates().widgetToWorld(event->pos());
|
||||
const QVector2D worldPos = widgetToWorld(event->pos());
|
||||
entt::entity hit = entityAtWorldPos(m_sim->getAdmin(), worldPos);
|
||||
|
||||
if (hit != entt::null)
|
||||
@@ -261,7 +287,7 @@ void ArenaView::keyPressEvent(QKeyEvent* event)
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates)
|
||||
void ArenaView::drawTiles(QPainter& painter)
|
||||
{
|
||||
const ArenaConfig& ac = m_sim->getArenaConfig();
|
||||
const int totalWidth = ac.playerBufferWidth_tiles
|
||||
@@ -274,12 +300,12 @@ void ArenaView::drawTiles(QPainter& painter, const WorldCoordinates& coordinates
|
||||
{
|
||||
for (int y = 0; y < totalHeight; ++y)
|
||||
{
|
||||
painter.fillRect(coordinates.tileRect(QPoint(x, y)), m_visuals->space.fill);
|
||||
painter.fillRect(tileRect(QPoint(x, y)), m_visuals->space.fill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordinates)
|
||||
void ArenaView::drawBuildings(QPainter& painter)
|
||||
{
|
||||
for (const Building& b : getAllBuildings(m_sim->getFactoryState()))
|
||||
{
|
||||
@@ -291,13 +317,13 @@ void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordin
|
||||
painter.setPen(Qt::NoPen);
|
||||
for (const QPoint& cell : b.bodyCells)
|
||||
{
|
||||
painter.fillRect(coordinates.tileRect(cell), bv.fill);
|
||||
painter.fillRect(tileRect(cell), bv.fill);
|
||||
}
|
||||
|
||||
const QPointF tl = coordinates.tileToWidget(b.anchor);
|
||||
const QPointF tl = tileToWidget(b.anchor);
|
||||
const QRectF bboxRect(tl.x(), tl.y(),
|
||||
b.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||
b.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||
b.footprint.width() * static_cast<qreal>(getTilePx()),
|
||||
b.footprint.height() * static_cast<qreal>(getTilePx()));
|
||||
|
||||
painter.setPen(QPen(bv.outline, 1));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
@@ -311,16 +337,20 @@ void ArenaView::drawBuildings(QPainter& painter, const WorldCoordinates& coordin
|
||||
}
|
||||
}
|
||||
|
||||
void ArenaView::drawDebris(QPainter& painter, const WorldCoordinates& coordinates)
|
||||
void ArenaView::drawDebris(QPainter& painter)
|
||||
{
|
||||
const float r = getTilePx() * 0.2f;
|
||||
for (const DebrisInfo& debris : getAllDebrisInfo(m_sim->getAdmin()))
|
||||
{
|
||||
drawDebrisMarker(painter, coordinates,
|
||||
coordinates.worldToWidget(debris.position));
|
||||
const QPointF center = worldToWidget(debris.position);
|
||||
painter.setBrush(QColor(128, 110, 90));
|
||||
painter.setPen(QPen(QColor(50, 40, 30), 1));
|
||||
painter.drawEllipse(center,
|
||||
static_cast<qreal>(r), static_cast<qreal>(r));
|
||||
}
|
||||
}
|
||||
|
||||
void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordinates)
|
||||
void ArenaView::drawStations(QPainter& painter)
|
||||
{
|
||||
m_sim->getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||
[&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f, const HealthComponent& h)
|
||||
@@ -336,13 +366,13 @@ void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordina
|
||||
painter.setPen(Qt::NoPen);
|
||||
for (const QPoint& cell : sb.bodyCells)
|
||||
{
|
||||
painter.fillRect(coordinates.tileRect(cell), bv.fill);
|
||||
painter.fillRect(tileRect(cell), bv.fill);
|
||||
}
|
||||
|
||||
const QPointF tl = coordinates.tileToWidget(sb.anchor);
|
||||
const QPointF tl = tileToWidget(sb.anchor);
|
||||
const QRectF bboxRect(tl.x(), tl.y(),
|
||||
sb.footprint.width() * static_cast<qreal>(coordinates.getTilePx()),
|
||||
sb.footprint.height() * static_cast<qreal>(coordinates.getTilePx()));
|
||||
sb.footprint.width() * static_cast<qreal>(getTilePx()),
|
||||
sb.footprint.height() * static_cast<qreal>(getTilePx()));
|
||||
|
||||
painter.setPen(QPen(bv.outline, 1));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
@@ -350,9 +380,14 @@ void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordina
|
||||
|
||||
if (h.maxHp > 0.0f)
|
||||
{
|
||||
drawHealthBar(painter, coordinates, bboxRect.left(),
|
||||
bboxRect.bottom() + 1.0, bboxRect.width(),
|
||||
h.hp / h.maxHp, f.isEnemy);
|
||||
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
||||
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
|
||||
const qreal barY = bboxRect.bottom() + 1.0;
|
||||
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)
|
||||
@@ -364,10 +399,8 @@ void ArenaView::drawStations(QPainter& painter, const WorldCoordinates& coordina
|
||||
});
|
||||
}
|
||||
|
||||
void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates)
|
||||
void ArenaView::drawShips(QPainter& painter)
|
||||
{
|
||||
const float forward = getShipForwardExtentPx(coordinates);
|
||||
|
||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, FacingComponent,
|
||||
FactionComponent, HealthComponent>(
|
||||
[&](entt::entity e, const ShipIdentityComponent& si,
|
||||
@@ -378,22 +411,40 @@ void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates
|
||||
m_visuals->ships.find(si.schematicId);
|
||||
if (it == m_visuals->ships.end()) { return; }
|
||||
|
||||
const QPointF center = coordinates.worldToWidget(pos.value);
|
||||
drawShipBody(painter, coordinates, center, facing.radians,
|
||||
it->second.fill, it->second.outline);
|
||||
const QPointF center = worldToWidget(pos.value);
|
||||
const QVector2D dir(std::cos(facing.radians), std::sin(facing.radians));
|
||||
const QVector2D perp(-dir.y(), dir.x());
|
||||
|
||||
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)
|
||||
{
|
||||
const qreal barW = static_cast<qreal>(forward) * 2.0;
|
||||
const qreal barX = center.x() - static_cast<qreal>(forward);
|
||||
const qreal barY = center.y() + static_cast<qreal>(forward) + 1.0;
|
||||
drawHealthBar(painter, coordinates, barX, barY, barW,
|
||||
h.hp / h.maxHp, fac.isEnemy);
|
||||
const float fraction = std::max(0.0f, h.hp / h.maxHp);
|
||||
const qreal barW = static_cast<qreal>(fwd) * 2.0;
|
||||
const qreal barH = static_cast<qreal>(getTilePx()) * 0.12;
|
||||
const qreal barX = center.x() - static_cast<qreal>(fwd);
|
||||
const qreal barY = center.y() + static_cast<qreal>(fwd) + 1.0;
|
||||
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)
|
||||
{
|
||||
const qreal radius = static_cast<qreal>(coordinates.getTilePx()) * 0.55;
|
||||
const qreal radius = static_cast<qreal>(getTilePx()) * 0.55;
|
||||
painter.setPen(QPen(QColor(255, 255, 0), 2));
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
painter.drawEllipse(center, radius, radius);
|
||||
@@ -401,9 +452,9 @@ void ArenaView::drawShips(QPainter& painter, const WorldCoordinates& coordinates
|
||||
});
|
||||
}
|
||||
|
||||
void ArenaView::drawDebugSensorRanges(QPainter& painter,
|
||||
const WorldCoordinates& coordinates)
|
||||
void ArenaView::drawDebugSensorRanges(QPainter& painter)
|
||||
{
|
||||
painter.setBrush(Qt::NoBrush);
|
||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent, SensorRangeComponent>(
|
||||
[&](entt::entity /*e*/, const ShipIdentityComponent& si,
|
||||
const PositionComponent& pos, const SensorRangeComponent& sensor)
|
||||
@@ -412,14 +463,17 @@ void ArenaView::drawDebugSensorRanges(QPainter& painter,
|
||||
m_visuals->ships.find(si.schematicId);
|
||||
if (it == m_visuals->ships.end()) { return; }
|
||||
|
||||
drawSensorRange(painter, coordinates,
|
||||
coordinates.worldToWidget(pos.value),
|
||||
sensor.value_tiles, it->second.outline);
|
||||
const QPointF center = worldToWidget(pos.value);
|
||||
const qreal radiusPx = static_cast<qreal>(sensor.value_tiles)
|
||||
* static_cast<qreal>(getTilePx());
|
||||
QColor circleColor = it->second.outline;
|
||||
circleColor.setAlpha(77);
|
||||
painter.setPen(QPen(circleColor, 1));
|
||||
painter.drawEllipse(center, radiusPx, radiusPx);
|
||||
});
|
||||
}
|
||||
|
||||
void ArenaView::drawDebugTargetLines(QPainter& painter,
|
||||
const WorldCoordinates& coordinates)
|
||||
void ArenaView::drawDebugTargetLines(QPainter& painter)
|
||||
{
|
||||
// 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
|
||||
@@ -437,8 +491,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter,
|
||||
QColor lineColor = it->second.fill;
|
||||
lineColor.setAlpha(128);
|
||||
painter.setPen(QPen(lineColor, 1));
|
||||
painter.drawLine(coordinates.worldToWidget(from),
|
||||
coordinates.worldToWidget(to));
|
||||
painter.drawLine(worldToWidget(from), worldToWidget(to));
|
||||
};
|
||||
|
||||
m_sim->getAdmin().forEach<ShipIdentityComponent, PositionComponent,
|
||||
@@ -483,7 +536,7 @@ void ArenaView::drawDebugTargetLines(QPainter& painter,
|
||||
});
|
||||
}
|
||||
|
||||
void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates)
|
||||
void ArenaView::drawBeams(QPainter& painter)
|
||||
{
|
||||
for (const ActiveBeam& beam : m_activeBeams)
|
||||
{
|
||||
@@ -499,7 +552,7 @@ void ArenaView::drawBeams(QPainter& painter, const WorldCoordinates& coordinates
|
||||
case BeamKind::Salvage: color = m_visuals->beams.salvageColor; break;
|
||||
}
|
||||
painter.setPen(QPen(color, m_visuals->beams.widthPx));
|
||||
painter.drawLine(coordinates.worldToWidget(*shooterPos),
|
||||
coordinates.worldToWidget(*targetPos + beam.targetOffset));
|
||||
painter.drawLine(worldToWidget(*shooterPos),
|
||||
worldToWidget(*targetPos + beam.targetOffset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
#include "Tick.h"
|
||||
#include "TickDriver.h"
|
||||
#include "VisualsConfig.h"
|
||||
#include "WorldCoordinates.h"
|
||||
|
||||
class ArenaSimulation;
|
||||
class QPainter;
|
||||
@@ -48,22 +47,22 @@ private slots:
|
||||
private:
|
||||
void handleEvent(std::shared_ptr<const BeamFiredEvent> event) override;
|
||||
|
||||
void drawTiles(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawBuildings(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawStations(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawDebris(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawShips(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawDebugSensorRanges(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawDebugTargetLines(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawBeams(QPainter& painter, const WorldCoordinates& coordinates);
|
||||
void drawTiles(QPainter& painter);
|
||||
void drawBuildings(QPainter& painter);
|
||||
void drawStations(QPainter& painter);
|
||||
void drawDebris(QPainter& painter);
|
||||
void drawShips(QPainter& painter);
|
||||
void drawDebugSensorRanges(QPainter& painter);
|
||||
void drawDebugTargetLines(QPainter& painter);
|
||||
void drawBeams(QPainter& painter);
|
||||
|
||||
// The world <-> widget transform for the current viewport size. The arena
|
||||
// shows the whole world at once and never scrolls, so this fits the arena's
|
||||
// full extent into the widget; like GameWorldView's, it is a per-frame
|
||||
// snapshot rather than cached state.
|
||||
WorldCoordinates getCoordinates() const;
|
||||
float getTilePx() const;
|
||||
QPointF worldToWidget(QVector2D worldPos) const;
|
||||
QPointF tileToWidget(QPoint tile) const;
|
||||
QRectF tileRect(QPoint tile) const;
|
||||
|
||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||
QVector2D widgetToWorld(QPoint widgetPt) const;
|
||||
|
||||
struct ActiveBeam
|
||||
{
|
||||
|
||||
@@ -9,10 +9,6 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsConfig.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
|
||||
)
|
||||
|
||||
@@ -27,6 +23,5 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/InspectWindow.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/ShipStatsPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/VisualsLoader.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../ui/WorldPrimitives.cpp
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
#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:
|
||||
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;
|
||||
}
|
||||
|
||||
const std::optional<BuildingId>&
|
||||
BuildModeController::getDeconstructHoverBuildingId() const
|
||||
{
|
||||
return m_deconstructHoverBuildingId;
|
||||
}
|
||||
|
||||
void BuildModeController::setDeconstructHoverBuildingId(std::optional<BuildingId> id)
|
||||
{
|
||||
m_deconstructHoverBuildingId = std::move(id);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
#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);
|
||||
|
||||
// --- 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;
|
||||
|
||||
std::optional<BuildingId> m_deconstructHoverBuildingId;
|
||||
};
|
||||
@@ -14,10 +14,6 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.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
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
@@ -29,10 +25,6 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DisplayName.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltDragPath.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
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
#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));
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
#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,13 +153,3 @@ std::optional<QPoint> findTunnelPartner(const TunnelLookup& lookup, QPoint tile,
|
||||
|
||||
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,7 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
|
||||
#include <QPoint>
|
||||
@@ -10,17 +9,6 @@
|
||||
#include "BuildingType.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
|
||||
// the direction it faces. Used by the tunnel pairing scan (REQ-BLD-TUNNEL-PAIR) and
|
||||
// the unified tunnel build mode (REQ-BLD-TUNNEL-MODE).
|
||||
@@ -34,13 +22,6 @@ struct TunnelTileInfo
|
||||
// direction, or std::nullopt when the tile holds no tunnel building.
|
||||
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
|
||||
// returns the first tile whose tunnel faces `targetFacing`. Tunnel buildings facing
|
||||
// any other direction are skipped, mirroring the "stop at the first same-direction
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
#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);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
#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);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
#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
|
||||
{
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
#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
|
||||
{
|
||||
};
|
||||
@@ -15,15 +15,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSaveRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/PanDirectionChangedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/PauseToggleRequestedEvent.h
|
||||
${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}/BuildingTypeSelectedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildHotkeyPressedEvent.h
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
#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;
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
// 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)
|
||||
// or on Restart. The build button bar re-evaluates which buttons are shown.
|
||||
// or on Restart. The build button grid re-evaluates which buttons are shown.
|
||||
class UnlockedBuildingsChangedEvent : public Event
|
||||
{
|
||||
};
|
||||
|
||||
@@ -3,14 +3,10 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
#include <cstddef>
|
||||
#include <map>
|
||||
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingsConfig.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "DisplayName.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
namespace
|
||||
@@ -53,21 +49,6 @@ std::optional<SelectedBuilding> resolvePlaceable(const Simulation& sim, Building
|
||||
resolved.bodyCells = building ? &building->bodyCells : &site->bodyCells;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Position of a building type in buildings.toml, which is the order the build button
|
||||
// bar lays out its buttons (REQ-UI-BUILD-BAR) and so the tie-break order for a
|
||||
// blueprint's contents line. A type with no config entry sorts after every known one.
|
||||
std::size_t configOrderIndex(BuildingType type, const BuildingsConfig& buildings)
|
||||
{
|
||||
for (std::size_t i = 0; i < buildings.buildings.size(); ++i)
|
||||
{
|
||||
if (buildings.buildings[i].type == type)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return buildings.buildings.size();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<BuildingConfig> readBuildingConfig(const Simulation& sim, BuildingId id)
|
||||
@@ -185,56 +166,3 @@ bool selectionHasPlaceableBuilding(const Simulation& sim,
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
|
||||
const Blueprint& blueprint, const BuildingsConfig& buildings)
|
||||
{
|
||||
std::map<BuildingType, int> counts;
|
||||
for (const BlueprintBuilding& building : blueprint.buildings)
|
||||
{
|
||||
++counts[building.type];
|
||||
}
|
||||
|
||||
// The config index is carried through the sort so the comparator stays a strict
|
||||
// total order; the enum value is the final tie-break, which only two config-less
|
||||
// types could ever reach.
|
||||
struct RankedType
|
||||
{
|
||||
BuildingType type;
|
||||
int count;
|
||||
std::size_t order;
|
||||
};
|
||||
std::vector<RankedType> ranked;
|
||||
ranked.reserve(counts.size());
|
||||
for (const std::pair<const BuildingType, int>& entry : counts)
|
||||
{
|
||||
ranked.push_back({entry.first, entry.second,
|
||||
configOrderIndex(entry.first, buildings)});
|
||||
}
|
||||
std::sort(ranked.begin(), ranked.end(),
|
||||
[](const RankedType& left, const RankedType& right)
|
||||
{
|
||||
if (left.count != right.count) { return left.count > right.count; }
|
||||
if (left.order != right.order) { return left.order < right.order; }
|
||||
return static_cast<int>(left.type) < static_cast<int>(right.type);
|
||||
});
|
||||
|
||||
std::vector<BlueprintContentEntry> summary;
|
||||
summary.reserve(ranked.size());
|
||||
for (const RankedType& entry : ranked)
|
||||
{
|
||||
summary.push_back({toDisplayName(buildingTypeId(entry.type)), entry.count});
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings)
|
||||
{
|
||||
int total = 0;
|
||||
for (const BlueprintBuilding& building : blueprint.buildings)
|
||||
{
|
||||
const BuildingDef* def = buildings.findBuildingDef(building.type);
|
||||
if (def) { total += def->cost; }
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
#include "ShipLayout.h"
|
||||
|
||||
class Simulation;
|
||||
struct BuildingsConfig;
|
||||
|
||||
// The user-configurable settings of a single building or construction site: the
|
||||
// selected recipe / ship schematic, the shipyard module layout, and (for
|
||||
@@ -50,29 +49,6 @@ Blueprint captureBlueprintFromSelection(const Simulation& sim,
|
||||
const std::vector<BuildingId>& selectedIds);
|
||||
|
||||
// True if any selected id refers to a player-placeable building or construction site
|
||||
// (the condition under which Ctrl+C opens the blueprint save dialog,
|
||||
// REQ-UI-BLUEPRINT-CREATE).
|
||||
// (the enable condition for the Create Blueprint button, REQ-UI-BLUEPRINT-CREATE).
|
||||
bool selectionHasPlaceableBuilding(const Simulation& sim,
|
||||
const std::vector<BuildingId>& selectedIds);
|
||||
|
||||
// One "<building name> x <count>" entry of a blueprint card's contents line
|
||||
// (REQ-UI-BLUEPRINT-CARD).
|
||||
struct BlueprintContentEntry
|
||||
{
|
||||
std::string buildingName;
|
||||
int count;
|
||||
};
|
||||
|
||||
// Summarizes what a blueprint holds: one entry per building type it contains, ordered
|
||||
// by descending count with ties broken by the order the types appear in buildings.toml
|
||||
// (which is the order of the build button bar, REQ-UI-BUILD-BAR). Building types absent
|
||||
// from the config sort last. Derived display data for the blueprint card, kept here so
|
||||
// it is unit-testable rather than buried in the dialog (REQ-UI-BLUEPRINT-CARD).
|
||||
std::vector<BlueprintContentEntry> summarizeBlueprintContents(
|
||||
const Blueprint& blueprint, const BuildingsConfig& buildings);
|
||||
|
||||
// Plain sum of the placement cost of every building in the blueprint
|
||||
// (REQ-UI-BLUEPRINT-CARD). Distinct from the total charged on placement
|
||||
// (REQ-UI-BLUEPRINT-PLACE), which additionally excludes locked building types and
|
||||
// rotate-in-place targets; see GameWorldView's placement path.
|
||||
int computeBlueprintCost(const Blueprint& blueprint, const BuildingsConfig& buildings);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#include "FactoryQueries.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
|
||||
#include "PortGeometry.h"
|
||||
@@ -180,61 +179,3 @@ getSiteSplitterInfo(const FactoryState& state, const GameConfig& config, Buildin
|
||||
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,7 +12,6 @@
|
||||
#include "FactoryState.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Port.h"
|
||||
#include "TunnelCompletion.h"
|
||||
|
||||
// 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
|
||||
@@ -70,13 +69,3 @@ std::vector<Port> getInputPorts(const FactoryState& state, const GameConfig& con
|
||||
std::optional<BeltSystem::SplitterInfo> getSiteSplitterInfo(const FactoryState& state,
|
||||
const GameConfig& config,
|
||||
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);
|
||||
|
||||
@@ -119,82 +119,3 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state, con
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
|
||||
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,7 +5,6 @@
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "BeltDragPath.h"
|
||||
#include "BuildingId.h"
|
||||
#include "BuildingType.h"
|
||||
#include "FactoryState.h"
|
||||
@@ -37,35 +36,3 @@ std::optional<BuildingId> findRotateInPlaceTarget(const FactoryState& state,
|
||||
const GameConfig& config,
|
||||
BuildingType type, QPoint anchor,
|
||||
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 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);
|
||||
|
||||
@@ -56,7 +56,7 @@ static Rotation rotCCW(Rotation r)
|
||||
return Rotation::East;
|
||||
}
|
||||
|
||||
// Mirror of BlueprintLibrary::createBlueprintFromSelection: given per-building
|
||||
// Mirror of BlueprintPanel::createBlueprintFromSelection: given per-building
|
||||
// (anchor, bodyCells, type, rotation), compute Blueprint with floor-division
|
||||
// bounding-box center and per-building tile offsets.
|
||||
struct BuildingSpec
|
||||
@@ -149,7 +149,7 @@ static void applyRotationCCW(Blueprint& bp, const GameConfig& cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors BlueprintLibrary::createBlueprintFromSelection's player-placeable filter:
|
||||
// Mirrors BlueprintPanel::createBlueprintFromSelection's player-placeable filter:
|
||||
// building types absent from buildings.toml (HQ, stations) or with playerPlaceable=false
|
||||
// are silently excluded before the bounding-box center and offsets are computed.
|
||||
static Blueprint buildBlueprintFiltered(const std::vector<BuildingSpec>& specs,
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
#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());
|
||||
}
|
||||
@@ -139,100 +139,3 @@ TEST_CASE("readBuildingConfig returns nullopt for an unknown id", "[copyconfig]"
|
||||
Simulation sim(loadTestConfig(), 7);
|
||||
CHECK_FALSE(readBuildingConfig(sim, kInvalidBuildingId).has_value());
|
||||
}
|
||||
|
||||
// The derived facts a blueprint card shows (REQ-UI-BLUEPRINT-CARD). They live here
|
||||
// rather than in the dialog so they can be tested: src/ui is off the test include path.
|
||||
|
||||
namespace
|
||||
{
|
||||
Blueprint makeBlueprint(const std::vector<BuildingType>& types)
|
||||
{
|
||||
Blueprint blueprint;
|
||||
for (const BuildingType type : types)
|
||||
{
|
||||
BlueprintBuilding building;
|
||||
building.type = type;
|
||||
building.rotation = Rotation::East;
|
||||
blueprint.buildings.push_back(building);
|
||||
}
|
||||
return blueprint;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("computeBlueprintCost sums the constituent placement costs", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
// belt 2 + belt 2 + miner 15.
|
||||
const Blueprint blueprint = makeBlueprint(
|
||||
{BuildingType::Belt, BuildingType::Belt, BuildingType::Miner});
|
||||
CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 19);
|
||||
}
|
||||
|
||||
TEST_CASE("computeBlueprintCost ignores types absent from buildings.toml", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
REQUIRE(cfg.buildings.findBuildingDef(BuildingType::Hq) == nullptr);
|
||||
|
||||
const Blueprint blueprint = makeBlueprint({BuildingType::Belt, BuildingType::Hq});
|
||||
CHECK(computeBlueprintCost(blueprint, cfg.buildings) == 2);
|
||||
}
|
||||
|
||||
TEST_CASE("computeBlueprintCost is zero for an empty blueprint", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
CHECK(computeBlueprintCost(Blueprint{}, cfg.buildings) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("summarizeBlueprintContents orders by descending count", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
const Blueprint blueprint = makeBlueprint(
|
||||
{BuildingType::Belt, BuildingType::Smelter, BuildingType::Miner,
|
||||
BuildingType::Miner, BuildingType::Smelter, BuildingType::Miner});
|
||||
|
||||
const std::vector<BlueprintContentEntry> summary =
|
||||
summarizeBlueprintContents(blueprint, cfg.buildings);
|
||||
REQUIRE(summary.size() == 3);
|
||||
CHECK(summary[0].buildingName == "Miner");
|
||||
CHECK(summary[0].count == 3);
|
||||
CHECK(summary[1].buildingName == "Smelter");
|
||||
CHECK(summary[1].count == 2);
|
||||
CHECK(summary[2].buildingName == "Belt");
|
||||
CHECK(summary[2].count == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("summarizeBlueprintContents breaks ties in buildings.toml order", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
// Miner is declared after Belt in buildings.toml, which is the order the build
|
||||
// button bar lays its buttons out, so Belt wins the tie despite the equal counts
|
||||
// and despite sorting later by enum value.
|
||||
const Blueprint blueprint = makeBlueprint(
|
||||
{BuildingType::Miner, BuildingType::Belt, BuildingType::Miner, BuildingType::Belt});
|
||||
|
||||
const std::vector<BlueprintContentEntry> summary =
|
||||
summarizeBlueprintContents(blueprint, cfg.buildings);
|
||||
REQUIRE(summary.size() == 2);
|
||||
CHECK(summary[0].buildingName == "Belt");
|
||||
CHECK(summary[1].buildingName == "Miner");
|
||||
}
|
||||
|
||||
TEST_CASE("summarizeBlueprintContents spells multi-word ids as display names", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
|
||||
const Blueprint blueprint = makeBlueprint({BuildingType::TunnelEntry});
|
||||
const std::vector<BlueprintContentEntry> summary =
|
||||
summarizeBlueprintContents(blueprint, cfg.buildings);
|
||||
REQUIRE(summary.size() == 1);
|
||||
CHECK(summary[0].buildingName == "Tunnel Entry");
|
||||
}
|
||||
|
||||
TEST_CASE("summarizeBlueprintContents is empty for an empty blueprint", "[blueprint]")
|
||||
{
|
||||
const GameConfig cfg = loadTestConfig();
|
||||
CHECK(summarizeBlueprintContents(Blueprint{}, cfg.buildings).empty());
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ add_files(
|
||||
SurfaceMaskTest.cpp
|
||||
BeltDragPathTest.cpp
|
||||
TunnelCompletionTest.cpp
|
||||
WorldCoordinatesTest.cpp
|
||||
WorldCameraTest.cpp
|
||||
SelectionControllerTest.cpp
|
||||
BuildModeControllerTest.cpp
|
||||
BuildingTest.cpp
|
||||
BuildingConfigTest.cpp
|
||||
ShipTest.cpp
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
#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)});
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
#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));
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
#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));
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
#include "BlueprintLibrary.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <utility>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QFile>
|
||||
#include <QMessageBox>
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
#include "BlueprintPlacementRequestedEvent.h"
|
||||
#include "BlueprintSerializer.h"
|
||||
#include "EventManager.h"
|
||||
#include "ExitBlueprintModeRequestedEvent.h"
|
||||
|
||||
#include "BuildingConfig.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
BlueprintLibrary::BlueprintLibrary(Simulation* sim, const GameConfig* config,
|
||||
QWidget* dialogParent)
|
||||
: m_sim(sim)
|
||||
, m_config(config)
|
||||
, m_dialogParent(dialogParent)
|
||||
{
|
||||
loadFromDisk();
|
||||
registerForEvents();
|
||||
}
|
||||
|
||||
BlueprintLibrary::~BlueprintLibrary()
|
||||
{
|
||||
saveToDisk();
|
||||
unregisterForEvents();
|
||||
}
|
||||
|
||||
bool BlueprintLibrary::getCanCaptureSelection() const
|
||||
{
|
||||
// A construction site counts the same as an operational building (REQ-UI-BLUEPRINT-CREATE).
|
||||
return selectionHasPlaceableBuilding(*m_sim, m_selectedBuildingIds);
|
||||
}
|
||||
|
||||
void BlueprintLibrary::saveSelectionAs(const QString& name)
|
||||
{
|
||||
Blueprint blueprint = createBlueprintFromSelection();
|
||||
if (blueprint.buildings.empty()) { return; }
|
||||
|
||||
blueprint.name = name;
|
||||
m_blueprints.push_back(std::move(blueprint));
|
||||
}
|
||||
|
||||
const std::vector<Blueprint>& BlueprintLibrary::getBlueprints() const
|
||||
{
|
||||
return m_blueprints;
|
||||
}
|
||||
|
||||
QString BlueprintLibrary::getContentsSummary(int index) const
|
||||
{
|
||||
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return QString(); }
|
||||
|
||||
QStringList entries;
|
||||
for (const BlueprintContentEntry& entry : summarizeBlueprintContents(
|
||||
m_blueprints[static_cast<std::size_t>(index)], m_config->buildings))
|
||||
{
|
||||
// The same "<type> x <count>" notation the building multi-selection summary
|
||||
// uses (REQ-UI-MULTI-SELECTION), which also sidesteps plural forms.
|
||||
entries << QObject::tr("%1 x %2")
|
||||
.arg(QString::fromStdString(entry.buildingName))
|
||||
.arg(entry.count);
|
||||
}
|
||||
return entries.join(QStringLiteral(", "));
|
||||
}
|
||||
|
||||
int BlueprintLibrary::getCost(int index) const
|
||||
{
|
||||
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return 0; }
|
||||
return computeBlueprintCost(m_blueprints[static_cast<std::size_t>(index)],
|
||||
m_config->buildings);
|
||||
}
|
||||
|
||||
bool BlueprintLibrary::getCanAfford(int index) const
|
||||
{
|
||||
return m_sim->getBuildingBlocksStock() >= getCost(index);
|
||||
}
|
||||
|
||||
void BlueprintLibrary::remove(int index)
|
||||
{
|
||||
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
|
||||
|
||||
if (m_activeIndex == index)
|
||||
{
|
||||
m_activeIndex = std::nullopt;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ExitBlueprintModeRequestedEvent>());
|
||||
}
|
||||
else if (m_activeIndex.has_value() && *m_activeIndex > index)
|
||||
{
|
||||
--*m_activeIndex;
|
||||
}
|
||||
m_blueprints.erase(m_blueprints.begin() + index);
|
||||
}
|
||||
|
||||
void BlueprintLibrary::beginPlacement(int index)
|
||||
{
|
||||
if (index < 0 || index >= static_cast<int>(m_blueprints.size())) { return; }
|
||||
|
||||
m_activeIndex = index;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<BlueprintPlacementRequestedEvent>(
|
||||
m_blueprints[static_cast<std::size_t>(index)]));
|
||||
}
|
||||
|
||||
void BlueprintLibrary::handleEvent(std::shared_ptr<const SelectionChangedEvent> event)
|
||||
{
|
||||
m_selectedBuildingIds = event->ids;
|
||||
}
|
||||
|
||||
void BlueprintLibrary::handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> /*event*/)
|
||||
{
|
||||
m_activeIndex = std::nullopt;
|
||||
}
|
||||
|
||||
void BlueprintLibrary::handleEvent(
|
||||
std::shared_ptr<const TemporaryBlueprintRequestedEvent> /*event*/)
|
||||
{
|
||||
// Temporary blueprint (REQ-UI-BLUEPRINT-TEMP): build from the current selection and
|
||||
// enter placement mode without adding it to the list or persisting it. If nothing
|
||||
// player-placeable is selected, do nothing.
|
||||
Blueprint blueprint = createBlueprintFromSelection();
|
||||
if (blueprint.buildings.empty()) { return; }
|
||||
|
||||
// No saved blueprint is active while a temporary one is being placed.
|
||||
m_activeIndex = std::nullopt;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<BlueprintPlacementRequestedEvent>(std::move(blueprint)));
|
||||
}
|
||||
|
||||
Blueprint BlueprintLibrary::createBlueprintFromSelection() const
|
||||
{
|
||||
// Capture is shared, testable logic in lib/sim: it resolves each selected id as an
|
||||
// operational building or a construction site alike (REQ-UI-BLUEPRINT-CREATE,
|
||||
// REQ-UI-BLUEPRINT-STORAGE).
|
||||
return captureBlueprintFromSelection(*m_sim, m_selectedBuildingIds);
|
||||
}
|
||||
|
||||
void BlueprintLibrary::loadFromDisk()
|
||||
{
|
||||
// Load at startup (REQ-UI-BLUEPRINT-LOAD). Missing file: start empty, no error.
|
||||
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return; }
|
||||
try
|
||||
{
|
||||
m_blueprints = BlueprintSerializer::deserialize(file.readAll().toStdString());
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
QMessageBox::critical(m_dialogParent, QObject::tr("Load Failed"),
|
||||
QObject::tr("Failed to load blueprints:\n%1").arg(e.what()));
|
||||
m_blueprints.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void BlueprintLibrary::saveToDisk() const
|
||||
{
|
||||
// Persist on shutdown; write errors are silently ignored (REQ-UI-BLUEPRINT-SAVE).
|
||||
const QString path = QCoreApplication::applicationDirPath() + "/blueprints.toml";
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { return; }
|
||||
try
|
||||
{
|
||||
const std::string content = BlueprintSerializer::serialize(m_blueprints);
|
||||
file.write(QByteArray::fromStdString(content));
|
||||
}
|
||||
catch (...) {}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "Blueprint.h"
|
||||
#include "BlueprintModeExitedEvent.h"
|
||||
#include "BuildingId.h"
|
||||
#include "EventHandler.h"
|
||||
#include "GameConfig.h"
|
||||
#include "SelectionChangedEvent.h"
|
||||
#include "TemporaryBlueprintRequestedEvent.h"
|
||||
|
||||
class Simulation;
|
||||
class QWidget;
|
||||
|
||||
// The player's saved blueprints: the list itself, its persistence (REQ-UI-BLUEPRINT-SAVE,
|
||||
// REQ-UI-BLUEPRINT-LOAD), capture from the current selection (REQ-UI-BLUEPRINT-CREATE,
|
||||
// REQ-UI-BLUEPRINT-TEMP), and entry into blueprint placement mode.
|
||||
//
|
||||
// Deliberately not a widget: blueprints have no permanent place on screen any more
|
||||
// (REQ-UI-BLUEPRINT-DIALOG), and the modal dialogs that present them must be driven from
|
||||
// MainWindow, the only widget that can pause the game (ModalPauseScope) and raise the dim
|
||||
// overlay. This class is the model those dialogs read and mutate.
|
||||
class BlueprintLibrary : public CombinedEventHandler<SelectionChangedEvent,
|
||||
BlueprintModeExitedEvent,
|
||||
TemporaryBlueprintRequestedEvent>
|
||||
{
|
||||
public:
|
||||
// dialogParent parents the load-failure message box (REQ-UI-BLUEPRINT-LOAD) and is
|
||||
// not owned.
|
||||
BlueprintLibrary(Simulation* sim, const GameConfig* config, QWidget* dialogParent);
|
||||
~BlueprintLibrary();
|
||||
|
||||
// True when the current selection holds at least one player-placeable building or
|
||||
// construction site -- the condition under which Ctrl+C has any effect
|
||||
// (REQ-UI-BLUEPRINT-CREATE).
|
||||
bool getCanCaptureSelection() const;
|
||||
|
||||
// Captures the current selection under the given name and appends it to the list.
|
||||
// Silently does nothing when nothing player-placeable is selected.
|
||||
void saveSelectionAs(const QString& name);
|
||||
|
||||
const std::vector<Blueprint>& getBlueprints() const;
|
||||
|
||||
// The blueprint's contents line, ready for its card: one "<name> x <count>" entry
|
||||
// per building type, comma-separated, ordered by summarizeBlueprintContents
|
||||
// (REQ-UI-BLUEPRINT-CARD).
|
||||
QString getContentsSummary(int index) const;
|
||||
|
||||
// Sum of the constituent buildings' placement costs (REQ-UI-BLUEPRINT-CARD).
|
||||
int getCost(int index) const;
|
||||
|
||||
// Whether the player can currently afford the blueprint's total cost, which is what
|
||||
// enables or greys out its card (REQ-UI-BLUEPRINT-CARD).
|
||||
bool getCanAfford(int index) const;
|
||||
|
||||
// Removes the blueprint, exiting blueprint placement mode if it was the active one
|
||||
// (REQ-UI-BLUEPRINT-DELETE).
|
||||
void remove(int index);
|
||||
|
||||
// Enters blueprint placement mode for the blueprint (REQ-UI-BLUEPRINT-MODE).
|
||||
void beginPlacement(int index);
|
||||
|
||||
private:
|
||||
void handleEvent(std::shared_ptr<const SelectionChangedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const BlueprintModeExitedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const TemporaryBlueprintRequestedEvent> event) override;
|
||||
|
||||
Blueprint createBlueprintFromSelection() const;
|
||||
void loadFromDisk();
|
||||
void saveToDisk() const;
|
||||
|
||||
// The simulation is the single source of truth for the block stock and the
|
||||
// selection; the change events are only refresh signals.
|
||||
Simulation* m_sim;
|
||||
const GameConfig* m_config;
|
||||
QWidget* m_dialogParent;
|
||||
std::vector<BuildingId> m_selectedBuildingIds;
|
||||
std::vector<Blueprint> m_blueprints;
|
||||
// Index of the blueprint currently in placement mode, so deleting it can exit that
|
||||
// mode (REQ-UI-BLUEPRINT-DELETE). nullopt = no saved blueprint is being placed.
|
||||
std::optional<int> m_activeIndex;
|
||||
};
|
||||
281
src/ui/BlueprintPanel.cpp
Normal file
281
src/ui/BlueprintPanel.cpp
Normal file
@@ -0,0 +1,281 @@
|
||||
#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)));
|
||||
}
|
||||
67
src/ui/BlueprintPanel.h
Normal file
67
src/ui/BlueprintPanel.h
Normal file
@@ -0,0 +1,67 @@
|
||||
#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;
|
||||
};
|
||||
@@ -1,333 +0,0 @@
|
||||
#include "BlueprintSelectionDialog.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
#include <QChar>
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
#include <QFrame>
|
||||
#include <QGridLayout>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QLabel>
|
||||
#include <QLayoutItem>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPoint>
|
||||
#include <QPushButton>
|
||||
#include <QRect>
|
||||
#include <QScrollArea>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
#include <QStyle>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "Blueprint.h"
|
||||
#include "BlueprintLibrary.h"
|
||||
#include "IconCaption.h"
|
||||
#include "ItemIconCache.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// Fixed at two, per REQ-UI-BLUEPRINT-DIALOG; the grid scrolls rather than reflowing.
|
||||
const int kGridColumnCount = 2;
|
||||
|
||||
// Card geometry. Every card is the same size so the grid stays even
|
||||
// (REQ-UI-BLUEPRINT-CARD); the width is chosen to fit a name and a short contents
|
||||
// line, and the height follows from three text rows.
|
||||
const int kCardWidthPx = 220;
|
||||
const int kCardPaddingPx = 10;
|
||||
const int kCardRowGapPx = 4;
|
||||
|
||||
// Side length of the per-card delete icon and of the dialog's close button.
|
||||
const int kSmallButtonSizePx = 22;
|
||||
|
||||
// Spacing between cards, and the dialog's own content margin.
|
||||
const int kSpacingPx = 8;
|
||||
|
||||
// Card rows visible before the grid scrolls. The half row is deliberate: a cut-off
|
||||
// card reads as "there is more below" at a glance.
|
||||
const double kVisibleRowCount = 2.5;
|
||||
|
||||
// U+00D7 MULTIPLICATION SIGN, the close and delete glyph. Written as a code point
|
||||
// because the sources are not guaranteed to be read as UTF-8 by every compiler.
|
||||
const QChar kCrossGlyph(0x00D7);
|
||||
|
||||
// Inner size of a card's composed face: the name row, the contents row, and the
|
||||
// cost row, plus the gap above the cost.
|
||||
QSize getCardFaceSize(const QFont& font)
|
||||
{
|
||||
const int rowHeight = QFontMetrics(font).height();
|
||||
return QSize(kCardWidthPx - 2 * kCardPaddingPx, rowHeight * 3 + kCardRowGapPx);
|
||||
}
|
||||
|
||||
QSize getCardSize(const QFont& font)
|
||||
{
|
||||
const QSize faceSize = getCardFaceSize(font);
|
||||
return QSize(faceSize.width() + 2 * kCardPaddingPx,
|
||||
faceSize.height() + 2 * kCardPaddingPx);
|
||||
}
|
||||
|
||||
// One card face: the blueprint name, its contents line below in a dimmed color,
|
||||
// and the cost with its block icon at the bottom (REQ-UI-BLUEPRINT-CARD). The three
|
||||
// are composed into a single pixmap because a QPushButton holds only one icon --
|
||||
// the same device the build button faces use.
|
||||
QPixmap composeCardFace(const QString& name, const QString& contents,
|
||||
const QPixmap& cost, const QFont& font,
|
||||
const QColor& textColor, const QColor& dimColor,
|
||||
const QSize& faceSize)
|
||||
{
|
||||
const QFontMetrics metrics(font);
|
||||
const int rowHeight = metrics.height();
|
||||
|
||||
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
|
||||
QPixmap face(static_cast<int>(faceSize.width() * dpr),
|
||||
static_cast<int>(faceSize.height() * dpr));
|
||||
face.setDevicePixelRatio(dpr);
|
||||
face.fill(Qt::transparent);
|
||||
|
||||
QPainter painter(&face);
|
||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||
painter.setFont(font);
|
||||
|
||||
// Both text rows are elided rather than wrapped or shrunk, so a long name or a
|
||||
// many-typed blueprint cannot change a card's height (REQ-UI-BLUEPRINT-CARD).
|
||||
painter.setPen(textColor);
|
||||
painter.drawText(QRect(0, 0, faceSize.width(), rowHeight),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
metrics.elidedText(name, Qt::ElideRight, faceSize.width()));
|
||||
|
||||
painter.setPen(dimColor);
|
||||
painter.drawText(QRect(0, rowHeight, faceSize.width(), rowHeight),
|
||||
Qt::AlignLeft | Qt::AlignVCenter,
|
||||
metrics.elidedText(contents, Qt::ElideRight, faceSize.width()));
|
||||
|
||||
const QSize costSize = getLogicalSize(cost);
|
||||
painter.drawPixmap(0, faceSize.height() - costSize.height(), cost);
|
||||
return face;
|
||||
}
|
||||
|
||||
// A composed card face and the size to show it at; the button needs both, and only
|
||||
// the composer knows the size it arrived at.
|
||||
struct CardFace { QIcon icon; QSize size; };
|
||||
|
||||
// The two-mode face of one card. The modes differ only in the text color, so an
|
||||
// unaffordable card greys itself when Qt swaps the pixmap on the disabled button
|
||||
// (REQ-UI-BLUEPRINT-CARD, consistent with REQ-UI-BUILD-DISABLED).
|
||||
CardFace buildCardFace(const QString& name, const QString& contents, int cost,
|
||||
const QPixmap& blockIcon, const QFont& font,
|
||||
const QPalette& palette)
|
||||
{
|
||||
const QSize faceSize = getCardFaceSize(font);
|
||||
const QColor dimColor = palette.color(QPalette::Disabled, QPalette::ButtonText);
|
||||
|
||||
CardFace result;
|
||||
for (QIcon::Mode mode : { QIcon::Normal, QIcon::Disabled })
|
||||
{
|
||||
const QColor textColor = palette.color(
|
||||
(mode == QIcon::Normal) ? QPalette::Active : QPalette::Disabled,
|
||||
QPalette::ButtonText);
|
||||
// The cost reads exactly like the header stock and the build button costs:
|
||||
// the number, then the block icon in place of a trailing "Blocks"
|
||||
// (REQ-UI-BLOCKS-ICON). A null icon leaves the bare number.
|
||||
const QPixmap costPixmap =
|
||||
renderCaptionWithIcon(QString::number(cost), blockIcon, font, textColor);
|
||||
const QPixmap face = composeCardFace(name, contents, costPixmap, font,
|
||||
textColor, dimColor, faceSize);
|
||||
result.icon.addPixmap(face, mode);
|
||||
result.size = result.size.expandedTo(getLogicalSize(face));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
BlueprintSelectionDialog::BlueprintSelectionDialog(BlueprintLibrary* library,
|
||||
ItemIconCache* itemIcons,
|
||||
QWidget* parent)
|
||||
: QDialog(parent)
|
||||
, m_library(library)
|
||||
, m_itemIcons(itemIcons)
|
||||
{
|
||||
setWindowTitle(tr("Blueprints"));
|
||||
setModal(true);
|
||||
// Frameless: the dialog draws its own header row, so an OS title bar would only
|
||||
// repeat it (REQ-UI-BLUEPRINT-DIALOG). Square corners rather than rounded ones --
|
||||
// rounding a top-level window needs a translucent background, which is unreliable
|
||||
// on Windows. The border matches the build bar and the side panel.
|
||||
setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
|
||||
setAttribute(Qt::WA_StyledBackground, true);
|
||||
setStyleSheet(QStringLiteral(
|
||||
"BlueprintSelectionDialog { background-color: palette(window);"
|
||||
" border: 1px solid palette(mid); }"));
|
||||
|
||||
QVBoxLayout* mainLayout = new QVBoxLayout(this);
|
||||
mainLayout->setContentsMargins(kSpacingPx, kSpacingPx, kSpacingPx, kSpacingPx);
|
||||
mainLayout->setSpacing(kSpacingPx);
|
||||
|
||||
QHBoxLayout* headerLayout = new QHBoxLayout();
|
||||
headerLayout->setSpacing(kSpacingPx);
|
||||
|
||||
QLabel* titleLabel = new QLabel(tr("Blueprints"), this);
|
||||
QFont headerFont = titleLabel->font();
|
||||
headerFont.setBold(true);
|
||||
titleLabel->setFont(headerFont);
|
||||
headerLayout->addWidget(titleLabel);
|
||||
|
||||
// Dimmed and bold, the treatment the build button hotkey badges use, so the
|
||||
// shortcut reads as a reminder rather than a second title (REQ-UI-BUILD-COST).
|
||||
QLabel* hotkeyBadge = new QLabel(tr("Ctrl+V"), this);
|
||||
hotkeyBadge->setFont(headerFont);
|
||||
QPalette badgePalette = hotkeyBadge->palette();
|
||||
badgePalette.setColor(hotkeyBadge->foregroundRole(),
|
||||
palette().color(QPalette::Disabled, QPalette::WindowText));
|
||||
hotkeyBadge->setPalette(badgePalette);
|
||||
headerLayout->addWidget(hotkeyBadge);
|
||||
|
||||
headerLayout->addStretch();
|
||||
|
||||
QPushButton* closeButton = new QPushButton(QString(kCrossGlyph), this);
|
||||
closeButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
|
||||
closeButton->setToolTip(tr("Close"));
|
||||
connect(closeButton, &QPushButton::clicked, this, &QDialog::reject);
|
||||
headerLayout->addWidget(closeButton);
|
||||
|
||||
mainLayout->addLayout(headerLayout);
|
||||
|
||||
QScrollArea* scrollArea = new QScrollArea(this);
|
||||
scrollArea->setWidgetResizable(true);
|
||||
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
scrollArea->setFrameShape(QFrame::NoFrame);
|
||||
|
||||
m_gridContainer = new QWidget(scrollArea);
|
||||
m_grid = new QGridLayout(m_gridContainer);
|
||||
m_grid->setContentsMargins(0, 0, 0, 0);
|
||||
m_grid->setSpacing(kSpacingPx);
|
||||
m_grid->setAlignment(Qt::AlignTop | Qt::AlignLeft);
|
||||
scrollArea->setWidget(m_gridContainer);
|
||||
mainLayout->addWidget(scrollArea, 1);
|
||||
|
||||
rebuildGrid();
|
||||
|
||||
// Fixed size (REQ-UI-BLUEPRINT-DIALOG), sized to show kVisibleRowCount card rows.
|
||||
const QSize cardSize = getCardSize(font());
|
||||
const int gridWidth = kGridColumnCount * cardSize.width()
|
||||
+ (kGridColumnCount - 1) * kSpacingPx;
|
||||
const int gridHeight = static_cast<int>(
|
||||
cardSize.height() * kVisibleRowCount + kSpacingPx * (kVisibleRowCount - 1.0));
|
||||
setFixedSize(gridWidth + style()->pixelMetric(QStyle::PM_ScrollBarExtent)
|
||||
+ 2 * kSpacingPx,
|
||||
gridHeight + kSmallButtonSizePx + 3 * kSpacingPx);
|
||||
|
||||
// A frameless dialog does not get Qt's automatic centering on its parent.
|
||||
if (parent)
|
||||
{
|
||||
const QRect parentRect = parent->window()->geometry();
|
||||
move(parentRect.center() - QPoint(width() / 2, height() / 2));
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<int> BlueprintSelectionDialog::getChosenIndex() const
|
||||
{
|
||||
return m_chosenIndex;
|
||||
}
|
||||
|
||||
void BlueprintSelectionDialog::rebuildGrid()
|
||||
{
|
||||
// deleteLater, not delete: this runs from a delete button's own clicked signal, and
|
||||
// destroying that button inside its emission would leave Qt holding a dangling
|
||||
// sender. hide() makes the removal immediate all the same.
|
||||
while (QLayoutItem* item = m_grid->takeAt(0))
|
||||
{
|
||||
if (item->widget())
|
||||
{
|
||||
item->widget()->hide();
|
||||
item->widget()->deleteLater();
|
||||
}
|
||||
delete item;
|
||||
}
|
||||
|
||||
const std::vector<Blueprint>& blueprints = m_library->getBlueprints();
|
||||
if (blueprints.empty())
|
||||
{
|
||||
QLabel* emptyLabel = new QLabel(
|
||||
tr("No blueprints yet.\n\nSelect one or more buildings and press Ctrl+C "
|
||||
"to save them as a blueprint."),
|
||||
m_gridContainer);
|
||||
emptyLabel->setAlignment(Qt::AlignCenter);
|
||||
emptyLabel->setWordWrap(true);
|
||||
m_grid->addWidget(emptyLabel, 0, 0, 1, kGridColumnCount);
|
||||
return;
|
||||
}
|
||||
|
||||
// Block icon shown to the right of each cost (REQ-UI-BLUEPRINT-CARD); null when no
|
||||
// building_block icon exists, in which case the cost is the bare number.
|
||||
const QPixmap blockIcon = m_itemIcons->hasIcon(kBlockItemId)
|
||||
? m_itemIcons->getPixmap(kBlockItemId, QFontMetrics(font()).height())
|
||||
: QPixmap();
|
||||
|
||||
const QSize cardSize = getCardSize(font());
|
||||
|
||||
for (int i = 0; i < static_cast<int>(blueprints.size()); ++i)
|
||||
{
|
||||
const Blueprint& blueprint = blueprints[static_cast<std::size_t>(i)];
|
||||
const CardFace face = buildCardFace(
|
||||
blueprint.name, m_library->getContentsSummary(i), m_library->getCost(i),
|
||||
blockIcon, font(), palette());
|
||||
|
||||
QWidget* card = new QWidget(m_gridContainer);
|
||||
card->setFixedSize(cardSize);
|
||||
|
||||
QVBoxLayout* cardLayout = new QVBoxLayout(card);
|
||||
cardLayout->setContentsMargins(0, 0, 0, 0);
|
||||
|
||||
QPushButton* body = new QPushButton(card);
|
||||
body->setIcon(face.icon);
|
||||
body->setIconSize(face.size);
|
||||
// An unaffordable card is greyed and does not respond, but its delete icon
|
||||
// stays live (REQ-UI-BLUEPRINT-CARD).
|
||||
body->setEnabled(m_library->getCanAfford(i));
|
||||
cardLayout->addWidget(body);
|
||||
|
||||
// A sibling of the body rather than one of its children, and outside the
|
||||
// layout: Qt disables a widget's children along with it, and the delete icon
|
||||
// must stay enabled on an unaffordable card (REQ-UI-BLUEPRINT-DELETE).
|
||||
QPushButton* deleteButton = new QPushButton(QString(kCrossGlyph), card);
|
||||
deleteButton->setFixedSize(kSmallButtonSizePx, kSmallButtonSizePx);
|
||||
deleteButton->setToolTip(tr("Delete blueprint"));
|
||||
deleteButton->move(cardSize.width() - kSmallButtonSizePx - kCardPaddingPx / 2,
|
||||
cardSize.height() - kSmallButtonSizePx - kCardPaddingPx / 2);
|
||||
deleteButton->raise();
|
||||
|
||||
m_grid->addWidget(card, i / kGridColumnCount, i % kGridColumnCount);
|
||||
|
||||
const int index = i;
|
||||
connect(body, &QPushButton::clicked, this, [this, index]()
|
||||
{
|
||||
onCardClicked(index);
|
||||
});
|
||||
connect(deleteButton, &QPushButton::clicked, this, [this, index]()
|
||||
{
|
||||
onDeleteClicked(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void BlueprintSelectionDialog::onCardClicked(int index)
|
||||
{
|
||||
// Placement mode is entered by the caller once the dialog has closed
|
||||
// (REQ-UI-BLUEPRINT-CARD).
|
||||
m_chosenIndex = index;
|
||||
accept();
|
||||
}
|
||||
|
||||
void BlueprintSelectionDialog::onDeleteClicked(int index)
|
||||
{
|
||||
// No confirmation prompt, and the dialog stays open (REQ-UI-BLUEPRINT-DELETE).
|
||||
m_library->remove(index);
|
||||
rebuildGrid();
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
#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
|
||||
};
|
||||
@@ -1,449 +0,0 @@
|
||||
#include "BuildButtonBar.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QColor>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QPainter>
|
||||
#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 "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;
|
||||
|
||||
// Rasterizes a chip SVG straight at its on-screen size times the device pixel
|
||||
// ratio, so it stays crisp without a downscale step.
|
||||
QPixmap renderChip(const QByteArray& svg)
|
||||
{
|
||||
const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
|
||||
QSvgRenderer renderer(svg);
|
||||
QPixmap pixmap(static_cast<int>(kIconSize.width() * dpr),
|
||||
static_cast<int>(kIconSize.height() * dpr));
|
||||
pixmap.setDevicePixelRatio(dpr);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
renderer.render(&painter);
|
||||
return pixmap;
|
||||
}
|
||||
|
||||
// Normal and grey-background chip pixmaps for a "<id>.svg" file. Empty pixmaps if
|
||||
// the file cannot be read; the caller then falls back to a name caption
|
||||
// (REQ-UI-BUILD-ICON).
|
||||
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);
|
||||
|
||||
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
|
||||
// rounded background rect; the white glyph uses fill="none" and is left alone.
|
||||
QString greyed = QString::fromUtf8(svg);
|
||||
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
|
||||
const QRegularExpressionMatch match = fillPattern.match(greyed);
|
||||
if (match.hasMatch())
|
||||
{
|
||||
greyed.replace(match.capturedStart(), match.capturedLength(),
|
||||
QStringLiteral("fill=\"#5f636e\""));
|
||||
}
|
||||
result.grey = renderChip(greyed.toUtf8());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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,
|
||||
const std::string& iconDir,
|
||||
ItemIconCache* itemIcons, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
, m_iconDir(iconDir)
|
||||
, 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 side panels; 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 QString iconPath = QString::fromStdString(m_iconDir) + "/"
|
||||
+ QString::fromStdString(def.id) + ".svg";
|
||||
|
||||
const ButtonFace face = buildButtonFace(
|
||||
loadChipPixmaps(iconPath), 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(QString::fromStdString(m_iconDir) + "/deconstruct.svg"),
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
419
src/ui/BuildButtonGrid.cpp
Normal file
419
src/ui/BuildButtonGrid.cpp
Normal file
@@ -0,0 +1,419 @@
|
||||
#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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QRect>
|
||||
#include <QWidget>
|
||||
|
||||
#include "BuilderModeExitedEvent.h"
|
||||
@@ -22,34 +21,25 @@ class QPushButton;
|
||||
class Simulation;
|
||||
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>
|
||||
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-ICON), read from disk at runtime like the config files.
|
||||
// (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.
|
||||
BuildButtonBar(Simulation* sim, const GameConfig* config,
|
||||
const std::string& iconDir, 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);
|
||||
BuildButtonGrid(Simulation* sim, const GameConfig* config,
|
||||
const std::string& iconDir, ItemIconCache* itemIcons,
|
||||
QWidget* parent = nullptr);
|
||||
~BuildButtonGrid() override;
|
||||
|
||||
void clearActiveButton();
|
||||
|
||||
@@ -63,11 +53,6 @@ private:
|
||||
// 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;
|
||||
@@ -87,5 +72,4 @@ private:
|
||||
std::map<BuildingType, int> m_costs;
|
||||
std::optional<std::size_t> m_activeIndex;
|
||||
QPushButton* m_deconstructButton;
|
||||
QRect m_viewRect;
|
||||
};
|
||||
@@ -6,15 +6,11 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalPauseScope.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}/BuildButtonBar.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h
|
||||
@@ -32,15 +28,11 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/MainWindow.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ModalDimOverlay.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}/BuildButtonBar.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintLibrary.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSelectionDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutDialog.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutPreview.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@
|
||||
#include <QVector2D>
|
||||
|
||||
#include "Blueprint.h"
|
||||
#include "BuildModeController.h"
|
||||
#include "BuildingConfig.h"
|
||||
#include "BlueprintModeExitedEvent.h"
|
||||
#include "BlueprintPlacementRequestedEvent.h"
|
||||
@@ -32,13 +31,6 @@
|
||||
#include "EventHandler.h"
|
||||
#include "ExitBlueprintModeRequestedEvent.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 "ArtifactCountChangedEvent.h"
|
||||
#include "BeamFiredEvent.h"
|
||||
@@ -52,16 +44,11 @@
|
||||
#include "CommandManager.h"
|
||||
#include "EntitySelectionChangedEvent.h"
|
||||
#include "GameConfig.h"
|
||||
#include "PlacementRules.h"
|
||||
#include "Rotation.h"
|
||||
#include "SelectionController.h"
|
||||
#include "Tick.h"
|
||||
#include "TickDriver.h"
|
||||
#include "TunnelCompletion.h"
|
||||
#include "VisualsConfig.h"
|
||||
#include "WorldCamera.h"
|
||||
#include "WorldCoordinates.h"
|
||||
#include "WorldRenderer.h"
|
||||
|
||||
struct Command;
|
||||
struct ParsedReplay;
|
||||
@@ -69,6 +56,19 @@ class ItemIconCache;
|
||||
class ReplayPlayer;
|
||||
class Simulation;
|
||||
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,
|
||||
public CombinedEventHandler<BeamFiredEvent,
|
||||
@@ -78,12 +78,6 @@ class GameWorldView : public QOpenGLWidget,
|
||||
BlueprintPlacementRequestedEvent,
|
||||
ExitBlueprintModeRequestedEvent,
|
||||
SpeedChangeRequestedEvent,
|
||||
PanDirectionChangedEvent,
|
||||
PauseToggleRequestedEvent,
|
||||
SpeedStepRequestedEvent,
|
||||
GhostRotationRequestedEvent,
|
||||
ModeCancelRequestedEvent,
|
||||
DebugDrawToggleRequestedEvent,
|
||||
CommandRequestedEvent>
|
||||
{
|
||||
Q_OBJECT
|
||||
@@ -106,14 +100,8 @@ public:
|
||||
protected:
|
||||
void initializeGL() 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 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 mouseMoveEvent(QMouseEvent* event) override;
|
||||
void mouseReleaseEvent(QMouseEvent* event) override;
|
||||
@@ -129,12 +117,6 @@ private:
|
||||
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> 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 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;
|
||||
|
||||
// Enqueue a sim command onto the CommandManager (the single mutation path).
|
||||
@@ -147,13 +129,29 @@ private:
|
||||
// Used to pre-validate placements whose UI follow-up depends on success.
|
||||
bool canAfford(BuildingType type) const;
|
||||
|
||||
// Screen-anchored chrome, drawn after the world (see WorldRenderer): these
|
||||
// need no world transform, which is exactly why they stayed here.
|
||||
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 drawTiles(QPainter& painter);
|
||||
void drawPortItems(QPainter& painter);
|
||||
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);
|
||||
// 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
|
||||
// the viewport edges to 0% toward the center.
|
||||
@@ -168,51 +166,110 @@ private:
|
||||
void drawVignetteBorder(QPainter& painter, const QColor& edgeColor);
|
||||
void drawReplayOverlay(QPainter& painter);
|
||||
|
||||
// Gathers the interaction state the renderer needs for this frame.
|
||||
WorldRenderFrame makeRenderFrame() const;
|
||||
|
||||
// The world <-> widget transform for the current viewport size and scroll
|
||||
// position. Cheap to build and deliberately not cached: it is a snapshot that
|
||||
// a resize or a scroll invalidates, so every user takes a fresh one.
|
||||
WorldCoordinates getCoordinates() const;
|
||||
float getTilePx() const;
|
||||
float getViewportWidthTiles() 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.
|
||||
float getViewLeftTiles() const;
|
||||
QPointF worldToWidget(QVector2D worldPos) const;
|
||||
QPointF tileToWidget(QPoint tile) 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 getEnemyStationRightEdge() const;
|
||||
// The camera's current pan limits, read fresh from the simulation each frame:
|
||||
// both edges move with asteroid expansion and with pushes (REQ-GW-SCROLL-LIMIT).
|
||||
ScrollBounds getScrollBounds() const;
|
||||
// Horizontal pan speed at a given view-center X, in tiles/s (REQ-UI-SCROLL-SPEED).
|
||||
float panSpeedTilesPerSecondAt(float viewCenterXTiles) const;
|
||||
void clampScroll();
|
||||
|
||||
// canPlaceBuilding (PlacementRules) against this view's simulation.
|
||||
bool canPlaceBuildingHere(BuildingType type, QPoint anchor, Rotation rot) const;
|
||||
bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const;
|
||||
std::optional<BuildingId> buildingAtTile(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);
|
||||
|
||||
// Drops despawned or fully-collected debris from the selection
|
||||
// (REQ-UI-DEBRIS-CLICK-SELECT). Called each frame from onFrame().
|
||||
std::optional<QVector2D> entityPosition(entt::entity entity) const;
|
||||
// Clears the debris selection, emitting an empty DebrisSelectionChangedEvent when
|
||||
// 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();
|
||||
// Drops despawned or dead actors from the selection (REQ-UI-ENTITY-CLICK-SELECT).
|
||||
// Called each frame from onFrame().
|
||||
// Clears the actor selection, emitting an empty EntitySelectionChangedEvent when it was
|
||||
// non-empty (REQ-UI-ENTITY-CLICK-SELECT). Used when buildings take over.
|
||||
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();
|
||||
// Resolves a click into the selection it should produce, applying the category
|
||||
// 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);
|
||||
// True if the given actor is part of the current actor selection.
|
||||
bool isEntitySelected(entt::entity entity) const;
|
||||
void stepSpeed(int delta);
|
||||
void placeAtTile(QPoint tile);
|
||||
|
||||
// Re-resolves the tunnel ghost type and completion partner from the current
|
||||
// ghost tile, rotation, and sub-tile cursor position, storing both on the build
|
||||
// mode controller. Only meaningful in tunnel mode (REQ-BLD-TUNNEL-MODE).
|
||||
// Unified tunnel build mode (REQ-BLD-TUNNEL-MODE). Active while the builder type
|
||||
// is TunnelEntry; the ghost then resolves to an entry or exit by hovered position.
|
||||
bool inTunnelMode() const;
|
||||
// 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();
|
||||
// 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).
|
||||
// Recomputes the drag path from its anchor to cursorTile using the current ghost
|
||||
// orientation, and stores it on the build mode controller.
|
||||
// Per-path-tile decision, shared by ghost drawing and release-time placement.
|
||||
enum class BeltTileAction { PlaceNew, RotateInPlace, Invalid };
|
||||
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);
|
||||
// resolveBeltDragPath (PlacementRules) against this view's simulation.
|
||||
// Classifies each path tile against the current sim state, applying cumulative
|
||||
// affordability to the PlaceNew tiles.
|
||||
std::vector<BeltDragResolved> resolveBeltDragPath() const;
|
||||
// Enqueues placements and rotate-in-place commands for the resolved path.
|
||||
void applyBeltDragPath();
|
||||
@@ -223,11 +280,19 @@ private:
|
||||
void copyConfigFrom(BuildingId id);
|
||||
void pasteConfigTo(BuildingId id);
|
||||
|
||||
// Turns the ghost and refreshes everything that depends on its facing: placement
|
||||
// validity, the tunnel completion match, and an in-progress belt drag's path.
|
||||
// The mode transitions themselves live on m_buildMode.
|
||||
void enterBuilderMode(BuildingType type);
|
||||
void exitBuilderMode();
|
||||
void enterBlueprintMode(Blueprint blueprint);
|
||||
void exitBlueprintMode();
|
||||
void toggleDeconstructMode();
|
||||
void rotateGhost(bool clockwise);
|
||||
|
||||
struct ActiveBeam
|
||||
{
|
||||
BeamFiredEvent event;
|
||||
QVector2D targetOffset;
|
||||
};
|
||||
|
||||
// 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).
|
||||
static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3);
|
||||
@@ -236,6 +301,22 @@ private:
|
||||
const GameConfig* m_config;
|
||||
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.
|
||||
CommandManager m_commandManager;
|
||||
// A Reset command was enqueued; reset the view after the next drain applies it.
|
||||
@@ -244,32 +325,40 @@ private:
|
||||
// live input is ignored (the CommandManager is in replay mode).
|
||||
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;
|
||||
QElapsedTimer m_frameTimer;
|
||||
std::mt19937 m_rng;
|
||||
double m_gameSpeedMultiplier;
|
||||
double m_prevNonZeroSpeed;
|
||||
// Horizontal view position (REQ-UI-SCROLL). Owns the scroll position itself;
|
||||
// this widget only supplies the pan intent and the simulation-derived bounds.
|
||||
WorldCamera m_camera;
|
||||
// World-X (tiles) at the center of the viewport (see getViewLeftTiles()).
|
||||
float m_scrollXTiles;
|
||||
|
||||
QTimer* m_renderTimer;
|
||||
|
||||
std::vector<ActiveBeam> m_activeBeams;
|
||||
|
||||
// The active build mode (builder / blueprint / deconstruct, or none) and the
|
||||
// ghost, tunnel and belt-drag state belonging to it. Owns the transitions
|
||||
// between them; this widget supplies the parts that need the simulation.
|
||||
BuildModeController m_buildMode;
|
||||
|
||||
std::optional<BuildingType> m_builderType;
|
||||
Rotation m_ghostRotation;
|
||||
QPoint m_ghostTile;
|
||||
bool m_ghostValid;
|
||||
// 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
|
||||
// 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).
|
||||
// end tile closest to the cursor when snapping to a building (REQ-BLD-BELT-DRAG).
|
||||
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.
|
||||
@@ -279,29 +368,27 @@ private:
|
||||
// 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;
|
||||
|
||||
// Owns the selection across all three categories and the rules for moving
|
||||
// between them (REQ-UI-SELECTION-CATEGORIES), including publishing the change
|
||||
// events. This widget only resolves what was hit.
|
||||
SelectionController m_selection;
|
||||
std::vector<BuildingId> m_selectedBuildingIds;
|
||||
std::vector<entt::entity> m_selectedEntities;
|
||||
std::vector<entt::entity> m_selectedDebris;
|
||||
bool m_boxSelecting;
|
||||
QPoint m_boxStartTile;
|
||||
QPoint m_boxCurrentTile;
|
||||
|
||||
// Interprets this widget's key events into semantic actions and publishes them
|
||||
// (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_scrollLeft;
|
||||
bool m_scrollRight;
|
||||
bool m_gameOverShown;
|
||||
bool m_winShown;
|
||||
bool m_schematicChoiceShown;
|
||||
|
||||
@@ -21,6 +21,13 @@
|
||||
#include "SpeedChangeRequestedEvent.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 int HeaderBar::kSpeedCount = 5;
|
||||
|
||||
|
||||
@@ -46,11 +46,3 @@ QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
|
||||
|
||||
return pixmap;
|
||||
}
|
||||
|
||||
QSize getLogicalSize(const QPixmap& pixmap)
|
||||
{
|
||||
if (pixmap.isNull()) { return QSize(0, 0); }
|
||||
const qreal dpr = pixmap.devicePixelRatio();
|
||||
return QSize(static_cast<int>(pixmap.width() / dpr),
|
||||
static_cast<int>(pixmap.height() / dpr));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
#include <QPixmap>
|
||||
#include <QSize>
|
||||
#include <QString>
|
||||
|
||||
// Renders `text` followed by `icon` to its right, vertically centered, onto a
|
||||
@@ -15,14 +14,3 @@
|
||||
// theme-correct; the result is devicePixelRatio-aware so text and icon stay crisp.
|
||||
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
|
||||
const QFont& font, const QColor& textColor);
|
||||
|
||||
// A pixmap's size in device-independent pixels. The pixmaps composed by
|
||||
// renderCaptionWithIcon and its callers are rasterized at the device pixel ratio, so
|
||||
// their raw size is not the size to lay them out at.
|
||||
QSize getLogicalSize(const QPixmap& pixmap);
|
||||
|
||||
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
|
||||
// word wherever a cost or stock is captioned (REQ-UI-BLOCKS-ICON, REQ-UI-BUILD-COST,
|
||||
// REQ-UI-EXPAND-BUTTON). It lives next to the caption helper because every caller of
|
||||
// one is a caller of the other.
|
||||
const char* const kBlockItemId = "building_block";
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
#include "InputMapper.h"
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
|
||||
#include <QKeyEvent>
|
||||
|
||||
#include "BlueprintSaveRequestedEvent.h"
|
||||
#include "BlueprintSelectionRequestedEvent.h"
|
||||
#include "BuildHotkeyPressedEvent.h"
|
||||
#include "BuildingType.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 "TemporaryBlueprintRequestedEvent.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)
|
||||
{
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Blueprint chords (REQ-UI-HOTKEYS). Checked ahead of the plain-key switch below,
|
||||
// which binds bare A/D/W/S/R/Q/T and must not fire on a Ctrl chord. Both requests
|
||||
// are decided by MainWindow, the only widget that can pause the game and dim the
|
||||
// window for a modal.
|
||||
if ((event->modifiers() & Qt::ControlModifier) != 0)
|
||||
{
|
||||
switch (event->key())
|
||||
{
|
||||
case Qt::Key_C:
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<BlueprintSaveRequestedEvent>());
|
||||
return true;
|
||||
case Qt::Key_V:
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<BlueprintSelectionRequestedEvent>());
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (event->key())
|
||||
{
|
||||
case Qt::Key_A:
|
||||
m_panLeftHeld = true;
|
||||
updatePanDirection();
|
||||
return true;
|
||||
case Qt::Key_D:
|
||||
m_panRightHeld = true;
|
||||
updatePanDirection();
|
||||
return true;
|
||||
case Qt::Key_Space:
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<PauseToggleRequestedEvent>());
|
||||
return true;
|
||||
case Qt::Key_W:
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SpeedStepRequestedEvent>(+1));
|
||||
return true;
|
||||
case Qt::Key_S:
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SpeedStepRequestedEvent>(-1));
|
||||
return true;
|
||||
case Qt::Key_R:
|
||||
// Shift reverses the rotation direction (REQ-BLD-ROTATE).
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<GhostRotationRequestedEvent>(
|
||||
(event->modifiers() & Qt::ShiftModifier) != 0));
|
||||
return true;
|
||||
case Qt::Key_Q:
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ModeCancelRequestedEvent>());
|
||||
return true;
|
||||
case Qt::Key_T:
|
||||
// Request a temporary blueprint from the current selection (REQ-UI-BLUEPRINT-TEMP).
|
||||
// The BlueprintLibrary owns the selection and blueprint-capture logic; it decides
|
||||
// whether anything placeable is selected and drives placement mode from there.
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<TemporaryBlueprintRequestedEvent>());
|
||||
return true;
|
||||
case Qt::Key_F3:
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<DebugDrawToggleRequestedEvent>());
|
||||
return true;
|
||||
case Qt::Key_Escape:
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<EscapeMenuRequestedEvent>());
|
||||
return true;
|
||||
case Qt::Key_F4:
|
||||
EventManager::getInstance()->addEvent(
|
||||
std::make_shared<TracePrintRequestedEvent>());
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool InputMapper::handleKeyRelease(QKeyEvent* event)
|
||||
{
|
||||
if (event->isAutoRepeat()) { return false; }
|
||||
|
||||
switch (event->key())
|
||||
{
|
||||
case Qt::Key_A:
|
||||
m_panLeftHeld = false;
|
||||
updatePanDirection();
|
||||
return true;
|
||||
case Qt::Key_D:
|
||||
m_panRightHeld = false;
|
||||
updatePanDirection();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "BuildingType.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; the bindings themselves are still hard-coded
|
||||
// here for now.
|
||||
//
|
||||
// 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.
|
||||
bool handleKeyPress(QKeyEvent* event);
|
||||
bool handleKeyRelease(QKeyEvent* event);
|
||||
|
||||
// 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,16 +9,13 @@
|
||||
#include <QCloseEvent>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QInputDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QResizeEvent>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "BlueprintLibrary.h"
|
||||
#include "BlueprintSelectionDialog.h"
|
||||
#include "BuildButtonBar.h"
|
||||
#include "BlueprintPanel.h"
|
||||
#include "BuildButtonGrid.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "Command.h"
|
||||
#include "CommandRequestedEvent.h"
|
||||
@@ -61,42 +58,37 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
||||
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
|
||||
m_itemIcons.get(), m_replay.get(), this);
|
||||
|
||||
// Building icons live alongside the config (a sibling of the config dir), read
|
||||
// from disk at runtime the same way visuals.toml is.
|
||||
const std::string iconDir = QDir::cleanPath(
|
||||
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
|
||||
|
||||
// Floats over the game world rather than living in the side panel column
|
||||
// (REQ-UI-BUILD-BAR). Creation order is the stacking order for siblings, so
|
||||
// building it after the world view puts it above the world and its vignettes,
|
||||
// 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(), iconDir,
|
||||
m_itemIcons.get(), this);
|
||||
|
||||
// The blueprints have no widget of their own: they are saved with Ctrl+C and picked
|
||||
// from a modal dialog (REQ-UI-BLUEPRINT-DIALOG), both driven from this window
|
||||
// because only it can pause the game and raise the dim overlay. Built after the
|
||||
// world view because loading blueprints.toml may put a message box on screen.
|
||||
m_blueprintLibrary = std::make_unique<BlueprintLibrary>(sim, &sim->getConfig(), this);
|
||||
|
||||
m_sidePanel = new QWidget(this);
|
||||
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
||||
sideLayout->setContentsMargins(1, 1, 1, 1);
|
||||
sideLayout->setSpacing(1);
|
||||
|
||||
// The selected building panel is the column's only panel and fills its height
|
||||
// (REQ-UI-PANEL-COLUMN).
|
||||
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
|
||||
sideLayout->addWidget(m_selectedBuildingPanel, 1);
|
||||
// Building icons live alongside the config (a sibling of the config dir), read
|
||||
// from disk at runtime the same way visuals.toml is.
|
||||
const std::string iconDir = QDir::cleanPath(
|
||||
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
|
||||
|
||||
// Draw a thin border around the side panel section. The class scoped selector keeps
|
||||
// the border on the panel itself rather than cascading onto its child widgets;
|
||||
// WA_StyledBackground lets the plain QWidget subclass honor the stylesheet box
|
||||
// (border/background).
|
||||
m_selectedBuildingPanel->setAttribute(Qt::WA_StyledBackground, true);
|
||||
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
|
||||
m_buildButtonGrid = new BuildButtonGrid(sim, &sim->getConfig(), iconDir, m_itemIcons.get(), m_sidePanel);
|
||||
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
|
||||
|
||||
sideLayout->addWidget(m_selectedBuildingPanel, 1);
|
||||
sideLayout->addWidget(m_buildButtonGrid, 1);
|
||||
sideLayout->addWidget(m_blueprintPanel, 1);
|
||||
|
||||
// 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 { border: 1px solid palette(mid); }"));
|
||||
"SelectedBuildingPanel, BuildButtonGrid, BlueprintPanel {"
|
||||
" border: 1px solid palette(mid); }"));
|
||||
|
||||
// Created last so it stacks above the other children; covers the whole window and
|
||||
// dims the game behind modal dialogs/menus (REQ-UI-MODAL-DIM).
|
||||
@@ -172,9 +164,6 @@ void MainWindow::layoutPanels()
|
||||
m_headerBar->setGeometry(0, 0, mainW, headerH);
|
||||
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
|
||||
m_sidePanel->setGeometry(mainW, 0, sideW, totalH);
|
||||
// Sizes itself to its buttons and centers along the bottom of the world view
|
||||
// (REQ-UI-BUILD-BAR).
|
||||
m_buildButtonBar->anchorTo(QRect(0, headerH, mainW, totalH - headerH));
|
||||
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
|
||||
}
|
||||
|
||||
@@ -370,47 +359,6 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> /*event*/)
|
||||
{
|
||||
// Ctrl+C has effect only when something player-placeable is selected; otherwise no
|
||||
// dialog opens at all (REQ-UI-BLUEPRINT-CREATE).
|
||||
if (!m_blueprintLibrary->getCanCaptureSelection()) { return; }
|
||||
|
||||
// Both scopes are held across the naming dialog and the selection dialog it hands
|
||||
// over to, so the dim never blinks off and the simulation is not resumed in between
|
||||
// (REQ-UI-MODAL-DIM).
|
||||
ModalPauseScope pause(*m_gameWorldView);
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
|
||||
bool ok = false;
|
||||
const QString name = QInputDialog::getText(
|
||||
this, tr("Create Blueprint"), tr("Blueprint name:"), QLineEdit::Normal,
|
||||
QString(), &ok);
|
||||
// Cancel, Escape, or an empty name: no blueprint, and no selection dialog.
|
||||
if (!ok || name.trimmed().isEmpty()) { return; }
|
||||
|
||||
m_blueprintLibrary->saveSelectionAs(name.trimmed());
|
||||
showBlueprintSelectionDialog();
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> /*event*/)
|
||||
{
|
||||
ModalPauseScope pause(*m_gameWorldView);
|
||||
ModalDimScope dim(*m_dimOverlay);
|
||||
showBlueprintSelectionDialog();
|
||||
}
|
||||
|
||||
void MainWindow::showBlueprintSelectionDialog()
|
||||
{
|
||||
BlueprintSelectionDialog dialog(m_blueprintLibrary.get(), m_itemIcons.get(), this);
|
||||
if (dialog.exec() == QDialog::Accepted && dialog.getChosenIndex().has_value())
|
||||
{
|
||||
// Entered after the dialog has closed, which is the order REQ-UI-BLUEPRINT-CARD
|
||||
// describes: clicking a card closes the dialog and enters placement mode.
|
||||
m_blueprintLibrary->beginPlacement(*dialog.getChosenIndex());
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
||||
{
|
||||
const Tick tick = m_sim->getCurrentTick();
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
#include "BlueprintSaveRequestedEvent.h"
|
||||
#include "BlueprintSelectionRequestedEvent.h"
|
||||
#include "BuildingId.h"
|
||||
#include "EscapeMenuRequestedEvent.h"
|
||||
#include "EventHandler.h"
|
||||
@@ -29,8 +27,8 @@ class Simulation;
|
||||
class GameWorldView;
|
||||
class HeaderBar;
|
||||
class SelectedBuildingPanel;
|
||||
class BuildButtonBar;
|
||||
class BlueprintLibrary;
|
||||
class BuildButtonGrid;
|
||||
class BlueprintPanel;
|
||||
class ItemIconCache;
|
||||
class QCloseEvent;
|
||||
class QResizeEvent;
|
||||
@@ -41,9 +39,7 @@ class MainWindow : public QWidget,
|
||||
WinEvent,
|
||||
EscapeMenuRequestedEvent,
|
||||
LayoutDialogRequestedEvent,
|
||||
RecipeSelectionRequestedEvent,
|
||||
BlueprintSaveRequestedEvent,
|
||||
BlueprintSelectionRequestedEvent>
|
||||
RecipeSelectionRequestedEvent>
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
@@ -63,8 +59,6 @@ private:
|
||||
void handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const BlueprintSaveRequestedEvent> event) override;
|
||||
void handleEvent(std::shared_ptr<const BlueprintSelectionRequestedEvent> event) override;
|
||||
|
||||
// Reloads the game config and visuals.toml from disk (REQ-CFG-RELOAD), shared
|
||||
// by every restart path. On success the reloaded visuals are applied to this
|
||||
@@ -77,12 +71,6 @@ private:
|
||||
void openShipLayoutDialog(BuildingId shipyardId,
|
||||
const std::string& schematicId,
|
||||
const ShipLayoutConfig& currentLayout);
|
||||
|
||||
// Runs the blueprint selection dialog and enters placement mode for whatever the
|
||||
// player picked (REQ-UI-BLUEPRINT-DIALOG). Holds no pause or dim scope of its own:
|
||||
// both callers already hold theirs, which is what keeps the dim continuous when a
|
||||
// confirmed save hands straight over to this dialog (REQ-UI-MODAL-DIM).
|
||||
void showBlueprintSelectionDialog();
|
||||
void layoutPanels();
|
||||
|
||||
private:
|
||||
@@ -90,15 +78,13 @@ private:
|
||||
VisualsConfig m_visuals;
|
||||
Simulation* m_sim;
|
||||
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
|
||||
// build bar, world view, and recipe dialog all rasterize the same SVGs.
|
||||
// build grid, world view, and recipe dialog all rasterize the same SVGs.
|
||||
std::unique_ptr<ItemIconCache> m_itemIcons;
|
||||
GameWorldView* m_gameWorldView;
|
||||
HeaderBar* m_headerBar;
|
||||
SelectedBuildingPanel* m_selectedBuildingPanel;
|
||||
BuildButtonBar* m_buildButtonBar;
|
||||
// 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;
|
||||
BuildButtonGrid* m_buildButtonGrid;
|
||||
BlueprintPanel* m_blueprintPanel;
|
||||
QWidget* m_sidePanel;
|
||||
ModalDimOverlay* m_dimOverlay = nullptr;
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
#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);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
#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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,175 +0,0 @@
|
||||
#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 "BuildingConfig.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;
|
||||
};
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// 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;
|
||||
const std::optional<BuildingConfig>& copiedConfig;
|
||||
const std::vector<CopyConfigFlash>& copyConfigFlashes;
|
||||
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 drawCopyConfigFeedback(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,
|
||||
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, 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. Shared by the selection highlight
|
||||
// and the copy-settings feedback (REQ-BLD-COPY-CONFIG-FEEDBACK).
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user