Compare commits
4 Commits
ui_changes
...
af410ee4de
| Author | SHA1 | Date | |
|---|---|---|---|
| af410ee4de | |||
| 8f6815f414 | |||
| 57ab63202f | |||
| dbabc3a563 |
@@ -60,6 +60,13 @@ keep the citation accurate.
|
|||||||
* don't use abbreviations, except very common ones ("s" for seconds, "min", "max", etc.)
|
* don't use abbreviations, except very common ones ("s" for seconds, "min", "max", etc.)
|
||||||
* if a variable holds a value that has a unit or if a function returns a value that has a unit, append that unit to the name (e.g. "m_shipVelocity_mps", "getAcceleration_mpss()")
|
* if a variable holds a value that has a unit or if a function returns a value that has a unit, append that unit to the name (e.g. "m_shipVelocity_mps", "getAcceleration_mpss()")
|
||||||
* always enclose scopes in braces
|
* always enclose scopes in braces
|
||||||
|
* keep source files ASCII-only. A non-ASCII character needed at runtime (a glyph in a
|
||||||
|
UI string, a symbol drawn on a widget) is written as its code point with a comment
|
||||||
|
naming it (`const QChar shiftGlyph(0x21E7); // U+21E7 UPWARDS WHITE ARROW`), never
|
||||||
|
as a literal character: MSVC 2017 does not read the sources as UTF-8 by default and
|
||||||
|
silently mangles them. Never round-trip a source file through
|
||||||
|
`Get-Content`/`Set-Content` either: Windows PowerShell reads it as ANSI and writes
|
||||||
|
it back double-encoded with a BOM. Use the Edit/Write tools.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -96,6 +103,15 @@ output directories and copies the Qt DLLs.
|
|||||||
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
|
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
|
||||||
`--replay <file>` for view-only playback of a recorded run.
|
`--replay <file>` for view-only playback of a recorded run.
|
||||||
|
|
||||||
|
**Visual verification is the user's job.** Screen-capturing the app window does not
|
||||||
|
work here: `CopyFromScreen` and `PrintWindow` both return a blank white client area
|
||||||
|
even while the app is running and rendering normally, because the capture cannot read
|
||||||
|
the composited surface of the `QOpenGLWidget`-backed window. A blank capture therefore
|
||||||
|
says nothing about whether the UI works, so do not read one as a regression and do not
|
||||||
|
try to work around it. To check a UI change: build, run the tests, launch the app, and
|
||||||
|
ask the user to look at it. Redirecting the process's stdout/stderr to a file does
|
||||||
|
work and is worth checking for Qt warnings.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Catch2, single executable, links `lib` only — no QApplication, no display.
|
Catch2, single executable, links `lib` only — no QApplication, no display.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ Schematic drops: when an enemy station set is destroyed, the simulation generate
|
|||||||
|
|
||||||
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
|
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
|
||||||
|
|
||||||
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonGrid` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonGrid`).
|
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonBar` → `GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView` → `BuildButtonBar`).
|
||||||
|
|
||||||
### Reading Simulation State
|
### Reading Simulation State
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
|
|||||||
Three product targets plus tests:
|
Three product targets plus tests:
|
||||||
|
|
||||||
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
|
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
|
||||||
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button grid. Depends on `lib` and on Qt's OpenGL widgets module.
|
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button bar. 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`.
|
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
|
||||||
- `tests/` — Catch2 tests. Links only against `lib`.
|
- `tests/` — Catch2 tests. Links only against `lib`.
|
||||||
|
|
||||||
|
|||||||
@@ -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**.
|
- **Ghosts.** While dragging, a belt ghost (REQ-BLD-GHOST) is rendered on every path tile that would be acted on, instead of a single ghost under the cursor. Each ghost is oriented to point toward the next tile along the path toward the cursor, so the path forms one connected belt run that turns at the corner (curved belts along the path auto-derive per REQ-BLD-BELT); the final tile keeps the direction of its incoming step (unless the end tile is snapped to a building, in which case it points into the target — see **Snapping to a building**), and a single-tile path keeps the belt's current orientation. A tile occupied by only an existing belt or belt construction site is a valid target — its belt is re-oriented to follow the path — and shows a normal belt ghost. A tile occupied by a non-belt building or construction site, or otherwise an invalid belt position (REQ-BLD-PLACE-VALID), is drawn in the distinct invalid color, overriding the belt coloring. A tile whose new belt is unaffordable — the cumulative cost of the belts newly placed up to and including it exceeds the global stock — shows **no ghost at all**.
|
||||||
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
|
- **Placement on release.** No construction site is placed while dragging. On releasing the left mouse button, the path is applied in order (anchor to cursor): each cell occupied by only an existing belt or belt construction site has that belt re-oriented in place to its path direction, consuming no building blocks and preserving any construction progress (REQ-BLD-ROTATE-IN-PLACE); each empty, valid cell gets a new belt construction site, consuming building blocks from the global stock (REQ-BLD-COST). Cells occupied by a non-belt building or construction site, cells that are otherwise invalid (REQ-BLD-PLACE-VALID), and cells whose new belt can no longer be afforded once the running total has been spent are skipped. This supersedes the click-to-place of REQ-BLD-PLACE for belts, including both the single-tile case and multi-tile drags that pass over existing belts.
|
||||||
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
|
- **Right-click cancels the drag.** Right-clicking while a belt drag is in progress cancels it: the path is discarded, no construction site is placed, and belt builder mode stays active (the exception to REQ-BLD-BUILDER-MODE). Right-clicking when no drag is in progress exits builder mode as usual (REQ-BLD-BUILDER-MODE).
|
||||||
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button grid contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-GRID), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
|
- REQ-BLD-TUNNEL-MODE: **Unified tunnel build mode.** The build button bar contains a single **Tunnel** button rather than separate Tunnel Entry and Tunnel Exit buttons (REQ-UI-BUILD-BAR), activated by that button or by hotkey 3 (REQ-UI-HOTKEYS). This one builder mode places either a Tunnel Entry or a Tunnel Exit construction site depending on the hovered position, so the player never manually chooses between the two ends. Both remain distinct building types (REQ-BLD-TUNNEL-ENTRY, REQ-BLD-TUNNEL-EXIT) with their own costs and construction; only their build-menu entry point is unified.
|
||||||
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
|
- **Default type.** The ghost (REQ-BLD-GHOST) is a **Tunnel Entry** by default; clicking places a Tunnel Entry construction site (REQ-BLD-PLACE). Rotation (REQ-BLD-ROTATE) sets the ghost's facing direction as for any building.
|
||||||
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
|
- **Exit-completion match.** While the ghost is at a valid position, the game tests whether placing a **Tunnel Exit** at the hovered tile with the current ghost rotation would pair — per the pairing rules of REQ-BLD-TUNNEL-PAIR (same facing direction, within `tunnel_max_distance`, first same-direction building along the search, nearest-claim semantics) — with an existing Tunnel Entry. If so, that Entry is the **exit-completion match** and the ghost turns into a **Tunnel Exit**; clicking then places a Tunnel Exit construction site.
|
||||||
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
|
- **Entry-completion match.** The game also tests whether placing a **Tunnel Entry** at the hovered tile with the current ghost rotation would pair — again per REQ-BLD-TUNNEL-PAIR — with an existing Tunnel Exit. If so, that Exit is the **entry-completion match** and the ghost stays a Tunnel Entry.
|
||||||
@@ -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-UI-SCHEMATIC: Locked ship schematics are not shown in the shipyard's schematic-selection dialog (REQ-UI-SELECT-BUTTON).
|
||||||
|
|
||||||
- REQ-LOCK-BUILDING: A building type granted by an unlock group (REQ-LOCK-EXPLICIT) is **locked** until that group is awarded. A locked building type has no button in the build button grid (REQ-UI-BUILD-GRID) and cannot be placed, selected as a build tool, or triggered by its build hotkey (REQ-UI-HOTKEYS); its button appears in the grid only once the building type is unlocked. Building types not granted by any unlock group are available from game start. Lock state resets on Restart (REQ-CFG-RELOAD).
|
- REQ-LOCK-BUILDING: A building type granted by an unlock group (REQ-LOCK-EXPLICIT) is **locked** until that group is awarded. A locked building type has no button in the build button bar (REQ-UI-BUILD-BAR) and cannot be placed, selected as a build tool, or triggered by its build hotkey (REQ-UI-HOTKEYS); its button appears in the bar only once the building type is unlocked, at which point the bar re-centers. Building types not granted by any unlock group are available from game start. Lock state resets on Restart (REQ-CFG-RELOAD).
|
||||||
|
|
||||||
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
|
- REQ-LOCK-UI-SPLITTER: Item types that are not implicitly unlocked are excluded from splitter filter dropdowns (REQ-BLD-SPLITTER).
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro
|
|||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) containing the three UI panels stacked vertically:
|
The screen is divided into two columns: a main column (75% width) containing the header bar and game world, and a side panel column (25% width) containing the two UI panels stacked vertically. The build button bar (REQ-UI-BUILD-BAR) is not part of either column — it floats over the game world at its bottom center:
|
||||||
|
|
||||||
```
|
```
|
||||||
+--------------------------------------+--------------+
|
+--------------------------------------+--------------+
|
||||||
@@ -441,14 +441,12 @@ The screen is divided into two columns: a main column (75% width) containing the
|
|||||||
+--------------------------------------+ Selected |
|
+--------------------------------------+ Selected |
|
||||||
| | Building |
|
| | Building |
|
||||||
| | Panel |
|
| | Panel |
|
||||||
|
| Game World | |
|
||||||
| +--------------+
|
| +--------------+
|
||||||
| Game World | Build |
|
| | |
|
||||||
| | Button |
|
| +------------------+ | Blueprint |
|
||||||
| | Grid |
|
| | Build Button Bar | | Panel |
|
||||||
| +--------------+
|
+--------+------------------+----------+--------------+
|
||||||
| | Blueprint |
|
|
||||||
| | Panel |
|
|
||||||
+--------------------------------------+--------------+
|
|
||||||
(75% width) (25% width)
|
(75% width) (25% width)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -462,7 +460,7 @@ 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-DECONSTRUCT-BORDER: While deconstruct mode is active (REQ-UI-DECONSTRUCT-BUTTON, REQ-UI-HOTKEYS), a vignette border is drawn around the edges of the game world view to signal the mode, matching the geometry of the paused-state vignette (REQ-UI-PAUSE-BORDER): a 100-pixel thickness (capped at half the smaller viewport dimension on very small views) with the four sides meeting along mitred corner diagonals. It fades in the alpha channel from fully transparent at its inner (center-facing) edge to the deconstruct tint color at the viewport edge. The color — including its alpha, which sets the peak opacity at the viewport edge — is read from `visuals.toml [overlays].deconstruct_tint`, the same deconstruct-mode color used for the hover tint. The border is presentation-only and has no effect on the simulation. If the game is both paused and in deconstruct mode, both vignettes are drawn and compose over each other.
|
||||||
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
|
- REQ-UI-EXPAND-BUTTON: The header bar shows an asteroid expansion button captioned `Expand: <x>` followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, where `<x>` is the current expansion cost computed from `world.toml [expansion].cost_building_blocks_formula` at the current number of purchased expansions (REQ-EXP-COST). When no icon file exists for `building_block`, the caption falls back to the `Expand: <x> Blocks` text. Clicking the button unlocks the next asteroid expansion (REQ-EXP-UNLOCK, REQ-GW-ASTEROID-EXPAND), spending that many building blocks from the global stock. The button is disabled when the player cannot currently afford the cost (consistent with REQ-UI-BUILD-DISABLED). The caption updates as the cost changes with each purchased expansion.
|
||||||
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
|
- REQ-UI-WORLD-SIZE: The game world view occupies the full height below the header bar in the main column (75% of the screen width).
|
||||||
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into three equal-height panels stacked top to bottom: selected building panel (top), build button grid (middle), and blueprint panel (bottom).
|
- REQ-UI-PANEL-COLUMN: The side panel column occupies 25% of the screen width and the full screen height. It is divided into two equal-height panels stacked top to bottom: selected building panel (top) and blueprint panel (bottom). The build buttons are not part of this column; they float over the game world (REQ-UI-BUILD-BAR).
|
||||||
- REQ-UI-MODAL-DIM: While a modal dialog, menu, or full-screen state screen is open on top of the game, a transparent black overlay (a dim/scrim) is drawn over the **entire game window** — the header bar, the game world view, and the side panel column — behind that modal, so the game reads as inactive while the modal holds focus. The overlay is shown for every modal that auto-pauses the simulation — the escape menu (REQ-UI-GAME-MENU), the recipe/schematic selection dialog (REQ-UI-SELECT-BUTTON), the layout configuration dialog (REQ-MOD-UI-DIALOG), and the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP) — as well as the game-over screen (REQ-HQ-GAME-OVER) and the win screen (REQ-WIN-SCREEN), which end rather than pause the game. When modals are nested (for example the Create Blueprint name dialog (REQ-MOD-UI-BLUEPRINT-CREATE) opened from the layout configuration dialog), only a single dim is shown over the game window; nested modals do not stack additional overlays. The dim color and opacity are read from `visuals.toml [overlays]` (a semi-transparent black modal-dim color), consistent with the other overlay colors. The overlay is presentation-only and has no effect on the simulation.
|
- REQ-UI-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
|
### Game World
|
||||||
@@ -529,7 +527,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-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-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-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT).
|
||||||
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button grid); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
|
- REQ-UI-MULTI-SELECTION: When multiple buildings are selected, the panel shows how many of each building type are selected. No per-building detail is shown. The panel additionally shows the **total building block cost** of the selection — the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-BUTTON). Construction sites count at their building type's full placement cost regardless of construction progress.
|
||||||
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
|
- REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel. Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW).
|
||||||
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selected building panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selected building panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD).
|
- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the 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:
|
- 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,20 +551,23 @@ 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-MULTI-SELECT: Multiple pieces of debris can be selected by box-drag or by Ctrl+clicking individual pieces to add or remove them, mirroring building multi-select (REQ-UI-MULTI-SELECT). Debris shares the field-object category with ships and defence stations (REQ-UI-SELECTION-CATEGORIES), so a field selection may hold debris and actors together. Ctrl+clicking a piece of debris while a field selection is active adds or removes that piece within the same selection; Ctrl+clicking a piece of debris while a building selection is active first clears the buildings and begins a field selection (buildings win). Conversely, selecting a building while a field selection is active clears it. Box-drag disambiguation follows REQ-UI-MULTI-SELECT (a box covering any building selects buildings; a box covering no building selects the ships, defence stations, and debris within it).
|
||||||
- REQ-UI-DEBRIS-PANEL: When exactly one piece of debris is selected (and no actors, REQ-UI-FIELD-MULTI-SELECTION), the selected building panel shows a **debris stats panel** structured like the ship and station stats panels (REQ-UI-SHIP-STATS-PANEL, REQ-UI-STATION-STATS-PANEL): a **"Debris"** heading followed by a single stat row, **"Scrap"**, showing that piece's current remaining scrap amount (REQ-RES-DEBRIS-DROP), rendered in the same label/value style as a ship hull stat row. When more than one field object is selected — multiple pieces of debris, or debris together with actors — the debris are instead summarized within the compact count summary (REQ-UI-FIELD-MULTI-SELECTION): a "Debris x <count>" line giving the number of selected debris pieces, followed by a "Scrap x <total>" line summing the remaining scrap across all selected debris. The displayed scrap value(s) update as selected debris are partially collected or despawn (REQ-UI-DEBRIS-CLICK-SELECT).
|
- REQ-UI-DEBRIS-PANEL: When 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 Grid
|
### Build Button Bar
|
||||||
|
|
||||||
- REQ-UI-BUILD-GRID: All placeable building types are shown as a flat grid of buttons with no grouping. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each.
|
- REQ-UI-BUILD-BAR: All placeable building types are shown as a **single horizontal row** of buttons with no grouping and no wrapping, inside a widget that **floats over the game world view** (REQ-UI-WORLD-SIZE), horizontally centered and anchored at the bottom edge with a small margin. Tunnel Entry and Tunnel Exit share a single **Tunnel** button (REQ-BLD-TUNNEL-MODE) rather than one button each. The bar is sized to its buttons and re-centers whenever the set of shown buttons changes (REQ-LOCK-BUILDING) or the view is resized.
|
||||||
- REQ-UI-BUILD-COST: Each button caption shows the building name and its building block cost with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) in place of the trailing `Blocks` word, e.g. `Belt: 2` then a small block icon. When no icon file exists for `building_block`, the caption falls back to the text form, e.g. "Belt: 2 Blocks".
|
- **Overlay behavior.** The bar occludes the strip of the game world it covers; the world view itself keeps its full extent and the view's scrolling, ghost rendering, and tile geometry are unaffected (the world is not inset for the bar). The bar is drawn above the pause and deconstruct vignettes (REQ-UI-PAUSE-BORDER, REQ-UI-DECONSTRUCT-BORDER), which keep their full 100-pixel bottom band underneath it, and below the modal dim (REQ-UI-MODAL-DIM), which covers the entire game window including the bar.
|
||||||
- REQ-UI-BUILD-ICON: Each build button shows an icon alongside its caption. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-GRID) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button with its caption and no icon; it is not an error.
|
- **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-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-COST: Each button is **icon-only with a cost**, its face composed of three elements: the button's **hotkey badge** in the top-left corner, the building's icon (REQ-UI-BUILD-ICON) centered below it, and the building block cost centered under the icon, shown with the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON) to the right of the number in place of the trailing `Blocks` word, e.g. `2` then a small block icon. The building name is not shown on the button; it is shown in the button's hover tooltip instead (REQ-UI-BUILD-TOOLTIP). When no icon file exists for `building_block`, the cost is shown as the bare number. The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) has no cost and shows its name as a text caption in the cost's place.
|
||||||
|
- **Hotkey badge.** The badge names the build hotkey that activates the button (REQ-UI-HOTKEYS), so the player can learn the shortcuts from the bar itself. It is rendered dimmer than the cost so it reads as secondary, but at the same size and in bold, because a smaller badge is not legible. A plain-digit hotkey is shown as the bare digit (`1`, `2`, `3`); a Shift+digit hotkey is shown with an upwards arrow prefixed and no separator (`↑1` … `↑6`); the Deconstruct button shows `Q`. A button whose building type has no build hotkey shows no badge and keeps the same face size, so the row stays even.
|
||||||
|
- REQ-UI-BUILD-ICON: Each build button shows an icon. Icons are SVG files loaded at runtime from `data/icons/buildings/` (a sibling of the config directory, read the same way as `visuals.toml`), one file per button named after the building's id (e.g. `belt.svg`, `reprocessing_plant.svg`). The shared Tunnel button (REQ-UI-BUILD-BAR) uses `tunnel_entry.svg`; the Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) uses `deconstruct.svg`. Each icon is a rounded colored "chip" bearing a white line glyph, the chip color following the building's fill color in `visuals.toml`. A missing icon file leaves the button showing its building name as a text caption in place of the icon, so the button stays identifiable in the icon-only bar (REQ-UI-BUILD-COST); it is not an error.
|
||||||
|
- REQ-UI-BUILD-TOOLTIP: Each building-type button shows a hover tooltip consisting of the building name followed by the descriptive text defined for that building type in `buildings.toml` (the optional per-building tooltip field). Because the button caption is icon-only (REQ-UI-BUILD-COST), the name is always part of the tooltip; if a building type defines no tooltip text, the tooltip shows the name alone. This tooltip is distinct from the recipe/schematic selection tooltip (REQ-UI-SELECT-TOOLTIP). The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON.
|
||||||
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
- REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained.
|
||||||
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button grid. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
- REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage.
|
||||||
|
|
||||||
### Blueprint Panel
|
### Blueprint Panel
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is shown to the right of the build button grid. It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
|
- REQ-UI-BLUEPRINT-PANEL: The blueprint panel is the lower of the two side panel column panels, below the selected building panel (REQ-UI-PANEL-COLUMN). It contains, from top to bottom: a "Create Blueprint" button, and a list of blueprint entries (one per saved blueprint, in creation order). The panel has no Save or Load buttons; blueprints are persisted automatically (REQ-UI-BLUEPRINT-SAVE) and restored at startup (REQ-UI-BLUEPRINT-LOAD).
|
||||||
|
|
||||||
- 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-CREATE: The "Create Blueprint" button is enabled only when at least one player-placeable building (i.e. a building with a button in the build button bar) is currently selected; non-player-placeable buildings (HQ, defence stations) in the selection do not count toward this condition. A selected player-placeable building may be either an operational building or a construction site (a building placed but not yet fully built, REQ-BLD-SITE-CONFIG); both count toward this condition and are captured identically (REQ-UI-BLUEPRINT-STORAGE). When clicked, a modal dialog appears prompting the player to enter a name. The dialog has Confirm and Cancel buttons. Clicking Cancel closes the dialog with no effect. Clicking Confirm with a non-empty name creates a blueprint from the current selection, silently excluding any non-player-placeable buildings, and appends its button to the blueprint list.
|
||||||
|
|
||||||
- REQ-UI-BLUEPRINT-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-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.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING),
|
// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING),
|
||||||
// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP)
|
// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP)
|
||||||
// or on Restart. The build button grid re-evaluates which buttons are shown.
|
// or on Restart. The build button bar re-evaluates which buttons are shown.
|
||||||
class UnlockedBuildingsChangedEvent : public Event
|
class UnlockedBuildingsChangedEvent : public Event
|
||||||
{
|
{
|
||||||
};
|
};
|
||||||
|
|||||||
459
src/ui/BuildButtonBar.cpp
Normal file
459
src/ui/BuildButtonBar.cpp
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pixmap's size in device-independent pixels. The pixmaps composed here are
|
||||||
|
// rasterized at the device pixel ratio, so their raw size is not their layout size.
|
||||||
|
QSize getLogicalSize(const QPixmap& pixmap)
|
||||||
|
{
|
||||||
|
if (pixmap.isNull()) { return QSize(0, 0); }
|
||||||
|
const qreal dpr = pixmap.devicePixelRatio();
|
||||||
|
return QSize(static_cast<int>(pixmap.width() / dpr),
|
||||||
|
static_cast<int>(pixmap.height() / dpr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One button face: the hotkey badge in the top-left corner, the chip icon centered
|
||||||
|
// below it, and the caption — the cost, or the Deconstruct name — centered at the
|
||||||
|
// bottom (REQ-UI-BUILD-COST). The three are composed into a single pixmap because
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QRect>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
#include "BuilderModeExitedEvent.h"
|
#include "BuilderModeExitedEvent.h"
|
||||||
@@ -21,25 +22,34 @@ class QPushButton;
|
|||||||
class Simulation;
|
class Simulation;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
|
|
||||||
class BuildButtonGrid : public QWidget,
|
// The build menu: one horizontal row of build buttons floating over the game world
|
||||||
public CombinedEventHandler<BuilderModeExitedEvent,
|
// view (REQ-UI-BUILD-BAR). The bar is sized to its buttons; its owner hands it the
|
||||||
DeconstructModeChangedEvent,
|
// world view's rect through anchorTo() and it centers itself along that rect's
|
||||||
BuildHotkeyPressedEvent,
|
// bottom edge.
|
||||||
BuildingBlocksChangedEvent,
|
class BuildButtonBar : public QWidget,
|
||||||
UnlockedBuildingsChangedEvent>
|
public CombinedEventHandler<BuilderModeExitedEvent,
|
||||||
|
DeconstructModeChangedEvent,
|
||||||
|
BuildHotkeyPressedEvent,
|
||||||
|
BuildingBlocksChangedEvent,
|
||||||
|
UnlockedBuildingsChangedEvent>
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
// iconDir is the directory holding the per-building "<id>.svg" chip icons
|
// iconDir is the directory holding the per-building "<id>.svg" chip icons
|
||||||
// (REQ-UI-BUILD-GRID), read from disk at runtime like the config files.
|
// (REQ-UI-BUILD-ICON), read from disk at runtime like the config files.
|
||||||
// itemIcons is the window-wide per-item icon cache and supplies the
|
// 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
|
// building_block icon shown in each button's cost (REQ-UI-BUILD-COST). Not
|
||||||
// owned; must outlive this widget.
|
// owned; must outlive this widget.
|
||||||
BuildButtonGrid(Simulation* sim, const GameConfig* config,
|
BuildButtonBar(Simulation* sim, const GameConfig* config,
|
||||||
const std::string& iconDir, ItemIconCache* itemIcons,
|
const std::string& iconDir, ItemIconCache* itemIcons,
|
||||||
QWidget* parent = nullptr);
|
QWidget* parent = nullptr);
|
||||||
~BuildButtonGrid() override;
|
~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);
|
||||||
|
|
||||||
void clearActiveButton();
|
void clearActiveButton();
|
||||||
|
|
||||||
@@ -53,6 +63,11 @@ private:
|
|||||||
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
|
// unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden.
|
||||||
void updateVisibility();
|
void updateVisibility();
|
||||||
|
|
||||||
|
// Shrinks the bar to its currently shown buttons and re-centers it in the
|
||||||
|
// anchored rect (REQ-UI-BUILD-BAR). Does nothing until anchorTo() supplied that
|
||||||
|
// rect, so the construction-time call is harmless.
|
||||||
|
void recenter();
|
||||||
|
|
||||||
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
|
||||||
@@ -72,4 +87,5 @@ private:
|
|||||||
std::map<BuildingType, int> m_costs;
|
std::map<BuildingType, int> m_costs;
|
||||||
std::optional<std::size_t> m_activeIndex;
|
std::optional<std::size_t> m_activeIndex;
|
||||||
QPushButton* m_deconstructButton;
|
QPushButton* m_deconstructButton;
|
||||||
|
QRect m_viewRect;
|
||||||
};
|
};
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
#include "BuildButtonGrid.h"
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QColor>
|
|
||||||
#include <QFile>
|
|
||||||
#include <QFontMetrics>
|
|
||||||
#include <QGridLayout>
|
|
||||||
#include <QIcon>
|
|
||||||
#include <QPainter>
|
|
||||||
#include <QPaintEvent>
|
|
||||||
#include <QPalette>
|
|
||||||
#include <QPixmap>
|
|
||||||
#include <QPushButton>
|
|
||||||
#include <QRect>
|
|
||||||
#include <QRegularExpression>
|
|
||||||
#include <QSignalMapper>
|
|
||||||
#include <QSize>
|
|
||||||
#include <QString>
|
|
||||||
#include <QSvgRenderer>
|
|
||||||
|
|
||||||
#include "BuildingType.h"
|
|
||||||
#include "BuildingTypeSelectedEvent.h"
|
|
||||||
#include "DeconstructModeToggleRequestedEvent.h"
|
|
||||||
#include "DisplayName.h"
|
|
||||||
#include "EventManager.h"
|
|
||||||
#include "ExitBuilderModeRequestedEvent.h"
|
|
||||||
#include "ItemIconCache.h"
|
|
||||||
#include "Simulation.h"
|
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
// Pixel size the SVG chips are rasterized at; downscaled to the button icon size.
|
|
||||||
const int kIconRenderSize = 64;
|
|
||||||
const QSize kIconSize(28, 28);
|
|
||||||
|
|
||||||
QPixmap renderChip(const QByteArray& svg)
|
|
||||||
{
|
|
||||||
QSvgRenderer renderer(svg);
|
|
||||||
QPixmap pixmap(kIconRenderSize, kIconRenderSize);
|
|
||||||
pixmap.fill(Qt::transparent);
|
|
||||||
QPainter painter(&pixmap);
|
|
||||||
renderer.render(&painter);
|
|
||||||
return pixmap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builds a build-button icon from a "<id>.svg" chip file. The returned QIcon also
|
|
||||||
// carries a Disabled-mode pixmap whose chip background is recolored grey, so an
|
|
||||||
// unaffordable (disabled) button shows the grey variant automatically
|
|
||||||
// (REQ-UI-BUILD-DISABLED) without any extra work in updateAffordability().
|
|
||||||
QIcon loadBuildingIcon(const QString& path)
|
|
||||||
{
|
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::ReadOnly))
|
|
||||||
{
|
|
||||||
return QIcon();
|
|
||||||
}
|
|
||||||
const QByteArray svg = file.readAll();
|
|
||||||
|
|
||||||
QIcon icon;
|
|
||||||
icon.addPixmap(renderChip(svg), QIcon::Normal);
|
|
||||||
|
|
||||||
// Recolor only the chip background: the first "#rrggbb" fill in the file is the
|
|
||||||
// rounded background rect; the white glyph uses fill="none" and is left alone.
|
|
||||||
QString greyed = QString::fromUtf8(svg);
|
|
||||||
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
|
|
||||||
const QRegularExpressionMatch match = fillPattern.match(greyed);
|
|
||||||
if (match.hasMatch())
|
|
||||||
{
|
|
||||||
greyed.replace(match.capturedStart(), match.capturedLength(),
|
|
||||||
QStringLiteral("fill=\"#5f636e\""));
|
|
||||||
}
|
|
||||||
icon.addPixmap(renderChip(greyed.toUtf8()), QIcon::Disabled);
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal and grey-background chip pixmaps for a "<id>.svg" file, using the same
|
|
||||||
// recolor rule as loadBuildingIcon. Empty pixmaps if the file cannot be read.
|
|
||||||
struct ChipPixmaps { QPixmap normal; QPixmap grey; };
|
|
||||||
ChipPixmaps loadChipPixmaps(const QString& path)
|
|
||||||
{
|
|
||||||
QFile file(path);
|
|
||||||
if (!file.open(QIODevice::ReadOnly)) { return {}; }
|
|
||||||
const QByteArray svg = file.readAll();
|
|
||||||
|
|
||||||
ChipPixmaps result;
|
|
||||||
result.normal = renderChip(svg);
|
|
||||||
QString greyed = QString::fromUtf8(svg);
|
|
||||||
static const QRegularExpression fillPattern(QStringLiteral("fill=\"#[0-9a-fA-F]{6}\""));
|
|
||||||
const QRegularExpressionMatch match = fillPattern.match(greyed);
|
|
||||||
if (match.hasMatch())
|
|
||||||
{
|
|
||||||
greyed.replace(match.capturedStart(), match.capturedLength(),
|
|
||||||
QStringLiteral("fill=\"#5f636e\""));
|
|
||||||
}
|
|
||||||
result.grey = renderChip(greyed.toUtf8());
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A build button that paints its own face — chip icon at the left, the building
|
|
||||||
// name above the cost, and the building_block item icon after the cost number in
|
|
||||||
// place of the "Blocks" word (REQ-UI-BUILD-COST, REQ-UI-BUILD-ICON). Custom paint
|
|
||||||
// (rather than the native icon+text) is needed because a QPushButton holds only
|
|
||||||
// one icon; this stays adaptive to the button width and greys itself when the
|
|
||||||
// button is disabled/unaffordable (REQ-UI-BUILD-DISABLED).
|
|
||||||
class BuildButton : public QPushButton
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
BuildButton(const ChipPixmaps& chip, const QString& name,
|
|
||||||
const QString& costText, const QPixmap& blockIcon, QWidget* parent)
|
|
||||||
: QPushButton(parent)
|
|
||||||
, m_chip(chip)
|
|
||||||
, m_name(name)
|
|
||||||
, m_costText(costText)
|
|
||||||
, m_blockIcon(blockIcon)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
|
||||||
void paintEvent(QPaintEvent* event) override
|
|
||||||
{
|
|
||||||
QPushButton::paintEvent(event); // frame, checked/hover state
|
|
||||||
|
|
||||||
QPainter painter(this);
|
|
||||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
|
||||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
|
||||||
|
|
||||||
const bool on = isEnabled();
|
|
||||||
const QRect area = rect().adjusted(6, 4, -6, -4);
|
|
||||||
const int chipSize = kIconSize.width();
|
|
||||||
const QPixmap& chip = on ? m_chip.normal : m_chip.grey;
|
|
||||||
if (!chip.isNull())
|
|
||||||
{
|
|
||||||
painter.drawPixmap(
|
|
||||||
QRect(area.x(), area.y() + (area.height() - chipSize) / 2,
|
|
||||||
chipSize, chipSize), chip);
|
|
||||||
}
|
|
||||||
|
|
||||||
const QRect textArea(area.x() + chipSize + 6, area.y(),
|
|
||||||
area.width() - chipSize - 6, area.height());
|
|
||||||
const QFontMetrics metrics(font());
|
|
||||||
const int lineHeight = metrics.height();
|
|
||||||
painter.setFont(font());
|
|
||||||
painter.setPen(palette().color(on ? QPalette::Active : QPalette::Disabled,
|
|
||||||
QPalette::ButtonText));
|
|
||||||
|
|
||||||
// Name fills everything above the bottom cost line (word-wrapped).
|
|
||||||
painter.drawText(
|
|
||||||
QRect(textArea.x(), textArea.y(),
|
|
||||||
textArea.width(), textArea.height() - lineHeight),
|
|
||||||
Qt::AlignLeft | Qt::AlignVCenter | Qt::TextWordWrap, m_name);
|
|
||||||
|
|
||||||
// Cost line: "<n>" then the block icon (or "<n> Blocks" when no icon).
|
|
||||||
const int costY = textArea.bottom() - lineHeight + 1;
|
|
||||||
if (m_blockIcon.isNull())
|
|
||||||
{
|
|
||||||
painter.drawText(
|
|
||||||
QRect(textArea.x(), costY, textArea.width(), lineHeight),
|
|
||||||
Qt::AlignLeft | Qt::AlignVCenter,
|
|
||||||
QObject::tr("%1 Blocks").arg(m_costText));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const int costWidth = metrics.horizontalAdvance(m_costText);
|
|
||||||
painter.drawText(QRect(textArea.x(), costY, costWidth, lineHeight),
|
|
||||||
Qt::AlignLeft | Qt::AlignVCenter, m_costText);
|
|
||||||
const qreal iconDpr = m_blockIcon.devicePixelRatio();
|
|
||||||
const int iconW = static_cast<int>(m_blockIcon.width() / iconDpr);
|
|
||||||
const int iconH = static_cast<int>(m_blockIcon.height() / iconDpr);
|
|
||||||
if (!on) { painter.setOpacity(0.45); }
|
|
||||||
painter.drawPixmap(
|
|
||||||
QRect(textArea.x() + costWidth + 4, costY + (lineHeight - iconH) / 2,
|
|
||||||
iconW, iconH), m_blockIcon);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
ChipPixmaps m_chip;
|
|
||||||
QString m_name;
|
|
||||||
QString m_costText;
|
|
||||||
QPixmap m_blockIcon;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config,
|
|
||||||
const std::string& iconDir,
|
|
||||||
ItemIconCache* itemIcons, QWidget* parent)
|
|
||||||
: QWidget(parent)
|
|
||||||
, m_sim(sim)
|
|
||||||
, m_config(config)
|
|
||||||
, m_iconDir(iconDir)
|
|
||||||
, m_itemIcons(itemIcons)
|
|
||||||
{
|
|
||||||
QGridLayout* layout = new QGridLayout(this);
|
|
||||||
layout->setSpacing(4);
|
|
||||||
layout->setContentsMargins(4, 4, 4, 4);
|
|
||||||
|
|
||||||
QSignalMapper* mapper = new QSignalMapper(this);
|
|
||||||
int col = 0;
|
|
||||||
int row = 0;
|
|
||||||
const int kCols = 3;
|
|
||||||
|
|
||||||
// Block icon shown in each button's cost line (REQ-UI-BUILD-COST); null when no
|
|
||||||
// building_block icon exists, in which case buttons fall back to text costs.
|
|
||||||
const bool hasBlockIcon = m_itemIcons->hasIcon("building_block");
|
|
||||||
const QPixmap blockIcon = hasBlockIcon
|
|
||||||
? m_itemIcons->getPixmap("building_block", QFontMetrics(font()).height())
|
|
||||||
: QPixmap();
|
|
||||||
|
|
||||||
for (const BuildingDef& def : config->buildings.buildings)
|
|
||||||
{
|
|
||||||
if (!def.playerPlaceable)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Tunnel Entry and Tunnel Exit share a single "Tunnel" button; the exit is
|
|
||||||
// reached through the unified tunnel build mode, not its own button
|
|
||||||
// (REQ-BLD-TUNNEL-MODE, REQ-UI-BUILD-GRID). Both stay player-placeable so
|
|
||||||
// blueprints and cost totals still account for exits.
|
|
||||||
if (def.type == BuildingType::TunnelExit)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
m_types.push_back(def.type);
|
|
||||||
m_costs[def.type] = def.cost;
|
|
||||||
|
|
||||||
const QString name = (def.type == BuildingType::TunnelEntry)
|
|
||||||
? tr("Tunnel")
|
|
||||||
: QString::fromStdString(toDisplayName(def.id));
|
|
||||||
// Icon file name matches the building id (REQ-UI-BUILD-GRID); Tunnel Entry's
|
|
||||||
// "tunnel_entry.svg" serves the shared Tunnel button.
|
|
||||||
const QString iconPath = QString::fromStdString(m_iconDir) + "/"
|
|
||||||
+ QString::fromStdString(def.id) + ".svg";
|
|
||||||
|
|
||||||
QPushButton* btn = nullptr;
|
|
||||||
if (hasBlockIcon)
|
|
||||||
{
|
|
||||||
// Custom-painted button showing the cost with the block icon in place of
|
|
||||||
// the "Blocks" word (REQ-UI-BUILD-COST).
|
|
||||||
btn = new BuildButton(loadChipPixmaps(iconPath), name,
|
|
||||||
QString::number(def.cost), blockIcon, this);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fallback: native chip icon + text cost when no block icon exists.
|
|
||||||
btn = new QPushButton(name + "\n" + tr("%1 Blocks").arg(def.cost), this);
|
|
||||||
btn->setIcon(loadBuildingIcon(iconPath));
|
|
||||||
btn->setIconSize(kIconSize);
|
|
||||||
}
|
|
||||||
btn->setCheckable(true);
|
|
||||||
btn->setFixedHeight(48);
|
|
||||||
if (def.tooltip)
|
|
||||||
{
|
|
||||||
btn->setToolTip(QString::fromStdString(*def.tooltip));
|
|
||||||
}
|
|
||||||
layout->addWidget(btn, row, col);
|
|
||||||
|
|
||||||
const int idx = static_cast<int>(m_buttons.size());
|
|
||||||
m_buttons.push_back(btn);
|
|
||||||
mapper->setMapping(btn, idx);
|
|
||||||
connect(btn, &QPushButton::clicked, mapper, qOverload<>(&QSignalMapper::map));
|
|
||||||
|
|
||||||
++col;
|
|
||||||
if (col >= kCols)
|
|
||||||
{
|
|
||||||
col = 0;
|
|
||||||
++row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connect(mapper, qOverload<int>(&QSignalMapper::mapped), this, &BuildButtonGrid::onBuildButton);
|
|
||||||
|
|
||||||
m_deconstructButton = new QPushButton(tr("Deconstruct"), this);
|
|
||||||
m_deconstructButton->setCheckable(true);
|
|
||||||
m_deconstructButton->setFixedHeight(48);
|
|
||||||
m_deconstructButton->setIcon(loadBuildingIcon(
|
|
||||||
QString::fromStdString(m_iconDir) + "/deconstruct.svg"));
|
|
||||||
m_deconstructButton->setIconSize(kIconSize);
|
|
||||||
// Refund tooltip composed from world.refund_percentage (REQ-UI-DECONSTRUCT-BUTTON,
|
|
||||||
// REQ-BLD-DECONSTRUCT). A finished building refunds the configured percentage; a
|
|
||||||
// construction site removed before it is built is refunded in full. When the
|
|
||||||
// percentage is 100% both cases coincide, so the tooltip is simplified to one case.
|
|
||||||
const int refundPercentage = m_config->world.refundPercentage;
|
|
||||||
const QString deconstructTooltip = (refundPercentage >= 100)
|
|
||||||
? tr("Deconstruct buildings. Refunds %1% of the building block cost.")
|
|
||||||
.arg(refundPercentage)
|
|
||||||
: tr("Deconstruct buildings. A finished building refunds %1% of its building "
|
|
||||||
"block cost once removed; a construction site removed before it is built "
|
|
||||||
"is refunded in full.")
|
|
||||||
.arg(refundPercentage);
|
|
||||||
m_deconstructButton->setToolTip(deconstructTooltip);
|
|
||||||
layout->addWidget(m_deconstructButton, row, col);
|
|
||||||
connect(m_deconstructButton, &QPushButton::clicked, this, [this]() {
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<DeconstructModeToggleRequestedEvent>());
|
|
||||||
});
|
|
||||||
|
|
||||||
updateVisibility();
|
|
||||||
registerForEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
BuildButtonGrid::~BuildButtonGrid()
|
|
||||||
{
|
|
||||||
unregisterForEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::updateAffordability()
|
|
||||||
{
|
|
||||||
const int buildingBlocks = m_sim->getBuildingBlocksStock();
|
|
||||||
|
|
||||||
// If the currently selected tool can no longer be afforded, exit builder mode
|
|
||||||
// before recomputing button states so it does not stay selected. Clearing the
|
|
||||||
// active index first lets the loop below disable the now-unaffordable button.
|
|
||||||
if (m_activeIndex)
|
|
||||||
{
|
|
||||||
const BuildingType activeType = m_types[*m_activeIndex];
|
|
||||||
const std::map<BuildingType, int>::const_iterator it = m_costs.find(activeType);
|
|
||||||
const int cost = (it != m_costs.end()) ? it->second : 0;
|
|
||||||
if (buildingBlocks < cost)
|
|
||||||
{
|
|
||||||
clearActiveButton();
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<ExitBuilderModeRequestedEvent>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (std::size_t i = 0; i < m_buttons.size(); ++i)
|
|
||||||
{
|
|
||||||
const BuildingType type = m_types[i];
|
|
||||||
const std::map<BuildingType, int>::const_iterator it = m_costs.find(type);
|
|
||||||
const int cost = (it != m_costs.end()) ? it->second : 0;
|
|
||||||
m_buttons[i]->setEnabled(buildingBlocks >= cost || m_activeIndex == i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::updateVisibility()
|
|
||||||
{
|
|
||||||
// A locked building type's button is hidden until its unlock group is awarded
|
|
||||||
// (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay
|
|
||||||
// stable; they simply appear once the type is unlocked.
|
|
||||||
for (std::size_t i = 0; i < m_buttons.size(); ++i)
|
|
||||||
{
|
|
||||||
m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::clearActiveButton()
|
|
||||||
{
|
|
||||||
if (m_activeIndex)
|
|
||||||
{
|
|
||||||
m_buttons[*m_activeIndex]->setChecked(false);
|
|
||||||
}
|
|
||||||
m_activeIndex.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::onBuildButton(int index)
|
|
||||||
{
|
|
||||||
if (index < 0 || index >= static_cast<int>(m_buttons.size()))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const std::size_t idx = static_cast<std::size_t>(index);
|
|
||||||
|
|
||||||
if (m_activeIndex == idx)
|
|
||||||
{
|
|
||||||
clearActiveButton();
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<ExitBuilderModeRequestedEvent>());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (m_activeIndex)
|
|
||||||
{
|
|
||||||
m_buttons[*m_activeIndex]->setChecked(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
m_activeIndex = idx;
|
|
||||||
m_buttons[idx]->setChecked(true);
|
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
|
||||||
std::make_shared<BuildingTypeSelectedEvent>(m_types[idx]));
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuilderModeExitedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
clearActiveButton();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
updateAffordability();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
|
|
||||||
{
|
|
||||||
updateVisibility();
|
|
||||||
updateAffordability();
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const DeconstructModeChangedEvent> event)
|
|
||||||
{
|
|
||||||
m_deconstructButton->setChecked(event->active);
|
|
||||||
}
|
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event)
|
|
||||||
{
|
|
||||||
for (std::size_t i = 0; i < m_types.size(); ++i)
|
|
||||||
{
|
|
||||||
if (m_types[i] == event->type)
|
|
||||||
{
|
|
||||||
// Equivalent to clicking the build button: a disabled (unaffordable) or
|
|
||||||
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
|
|
||||||
// hotkey is likewise inert.
|
|
||||||
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
|
|
||||||
{
|
|
||||||
onBuildButton(static_cast<int>(i));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.h
|
||||||
@@ -35,7 +35,7 @@ SET(SRCS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldPrimitives.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WorldRenderer.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/HeaderBar.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonGrid.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildButtonBar.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/SelectedBuildingPanel.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/FieldSelectionPanel.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintPanel.cpp
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ void GameWorldView::onFrame()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unlocked building set changes only after a drop is applied or on Restart
|
// Unlocked building set changes only after a drop is applied or on Restart
|
||||||
// (REQ-LOCK-BUILDING); the build grid rebuilds its visible buttons.
|
// (REQ-LOCK-BUILDING); the build bar rebuilds its visible buttons.
|
||||||
int newUnlockedBuildingCount = 0;
|
int newUnlockedBuildingCount = 0;
|
||||||
for (const BuildingDef& def : m_sim->getConfig().buildings.buildings)
|
for (const BuildingDef& def : m_sim->getConfig().buildings.buildings)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,13 +21,6 @@
|
|||||||
#include "SpeedChangeRequestedEvent.h"
|
#include "SpeedChangeRequestedEvent.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
|
|
||||||
namespace
|
|
||||||
{
|
|
||||||
// Item id of the building blocks resource, whose icon stands in for the "Blocks"
|
|
||||||
// word in the header stock and expand button (REQ-UI-BLOCKS-ICON).
|
|
||||||
const char* const kBlockItemId = "building_block";
|
|
||||||
}
|
|
||||||
|
|
||||||
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
const double HeaderBar::kSpeeds[] = { 0.0, 0.5, 1.0, 2.0, 10.0 };
|
||||||
const int HeaderBar::kSpeedCount = 5;
|
const int HeaderBar::kSpeedCount = 5;
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,9 @@
|
|||||||
// theme-correct; the result is devicePixelRatio-aware so text and icon stay crisp.
|
// theme-correct; the result is devicePixelRatio-aware so text and icon stay crisp.
|
||||||
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
|
QPixmap renderCaptionWithIcon(const QString& text, const QPixmap& icon,
|
||||||
const QFont& font, const QColor& textColor);
|
const QFont& font, const QColor& textColor);
|
||||||
|
|
||||||
|
// 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";
|
||||||
|
|||||||
@@ -51,6 +51,29 @@ std::optional<BuildingType> buildHotkeyType(int digit, bool shiftHeld)
|
|||||||
|
|
||||||
} // namespace
|
} // 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)
|
bool InputMapper::handleKeyPress(QKeyEvent* event)
|
||||||
{
|
{
|
||||||
// Auto-repeat says nothing new about which keys are down, and a held action is
|
// Auto-repeat says nothing new about which keys are down, and a held action is
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "BuildingType.h"
|
||||||
#include "WorldCamera.h"
|
#include "WorldCamera.h"
|
||||||
|
|
||||||
class QKeyEvent;
|
class QKeyEvent;
|
||||||
@@ -24,6 +27,12 @@ class QKeyEvent;
|
|||||||
class InputMapper
|
class InputMapper
|
||||||
{
|
{
|
||||||
public:
|
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
|
// Both return true when the key was consumed; the caller passes anything else
|
||||||
// on to its base class so unrelated shortcuts keep working.
|
// on to its base class so unrelated shortcuts keep working.
|
||||||
bool handleKeyPress(QKeyEvent* event);
|
bool handleKeyPress(QKeyEvent* event);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "BlueprintPanel.h"
|
#include "BlueprintPanel.h"
|
||||||
#include "BuildButtonGrid.h"
|
#include "BuildButtonBar.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
#include "Command.h"
|
#include "Command.h"
|
||||||
#include "CommandRequestedEvent.h"
|
#include "CommandRequestedEvent.h"
|
||||||
@@ -58,36 +58,43 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
|||||||
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
|
m_gameWorldView = new GameWorldView(sim, &sim->getConfig(), &m_visuals, m_configDir,
|
||||||
m_itemIcons.get(), m_replay.get(), this);
|
m_itemIcons.get(), m_replay.get(), this);
|
||||||
|
|
||||||
m_sidePanel = new QWidget(this);
|
|
||||||
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
|
||||||
sideLayout->setContentsMargins(1, 1, 1, 1);
|
|
||||||
sideLayout->setSpacing(1);
|
|
||||||
|
|
||||||
// Building icons live alongside the config (a sibling of the config dir), read
|
// Building icons live alongside the config (a sibling of the config dir), read
|
||||||
// from disk at runtime the same way visuals.toml is.
|
// from disk at runtime the same way visuals.toml is.
|
||||||
const std::string iconDir = QDir::cleanPath(
|
const std::string iconDir = QDir::cleanPath(
|
||||||
QString::fromStdString(m_configDir) + "/../icons/buildings").toStdString();
|
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);
|
||||||
|
|
||||||
|
m_sidePanel = new QWidget(this);
|
||||||
|
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
||||||
|
sideLayout->setContentsMargins(1, 1, 1, 1);
|
||||||
|
sideLayout->setSpacing(1);
|
||||||
|
|
||||||
m_selectedBuildingPanel = new SelectedBuildingPanel(sim, &sim->getConfig(), m_sidePanel);
|
m_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);
|
m_blueprintPanel = new BlueprintPanel(sim, &sim->getConfig(), m_sidePanel);
|
||||||
|
|
||||||
|
// Equal stretch gives the two panels half the column height each
|
||||||
|
// (REQ-UI-PANEL-COLUMN).
|
||||||
sideLayout->addWidget(m_selectedBuildingPanel, 1);
|
sideLayout->addWidget(m_selectedBuildingPanel, 1);
|
||||||
sideLayout->addWidget(m_buildButtonGrid, 1);
|
|
||||||
sideLayout->addWidget(m_blueprintPanel, 1);
|
sideLayout->addWidget(m_blueprintPanel, 1);
|
||||||
|
|
||||||
// Draw a thin border around each of the three side-panel sections. The class
|
// Draw a thin border around each of the two side-panel sections. The class
|
||||||
// scoped selectors keep the border on the panels themselves rather than
|
// scoped selectors keep the border on the panels themselves rather than
|
||||||
// cascading onto their child widgets; WA_StyledBackground lets the plain
|
// cascading onto their child widgets; WA_StyledBackground lets the plain
|
||||||
// QWidget subclasses honor the stylesheet box (border/background).
|
// QWidget subclasses honor the stylesheet box (border/background).
|
||||||
for (QWidget* panel : { static_cast<QWidget*>(m_selectedBuildingPanel),
|
for (QWidget* panel : { static_cast<QWidget*>(m_selectedBuildingPanel),
|
||||||
static_cast<QWidget*>(m_buildButtonGrid),
|
|
||||||
static_cast<QWidget*>(m_blueprintPanel) })
|
static_cast<QWidget*>(m_blueprintPanel) })
|
||||||
{
|
{
|
||||||
panel->setAttribute(Qt::WA_StyledBackground, true);
|
panel->setAttribute(Qt::WA_StyledBackground, true);
|
||||||
}
|
}
|
||||||
m_sidePanel->setStyleSheet(QStringLiteral(
|
m_sidePanel->setStyleSheet(QStringLiteral(
|
||||||
"SelectedBuildingPanel, BuildButtonGrid, BlueprintPanel {"
|
"SelectedBuildingPanel, BlueprintPanel {"
|
||||||
" border: 1px solid palette(mid); }"));
|
" border: 1px solid palette(mid); }"));
|
||||||
|
|
||||||
// Created last so it stacks above the other children; covers the whole window and
|
// Created last so it stacks above the other children; covers the whole window and
|
||||||
@@ -164,6 +171,9 @@ void MainWindow::layoutPanels()
|
|||||||
m_headerBar->setGeometry(0, 0, mainW, headerH);
|
m_headerBar->setGeometry(0, 0, mainW, headerH);
|
||||||
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
|
m_gameWorldView->setGeometry(0, headerH, mainW, totalH - headerH);
|
||||||
m_sidePanel->setGeometry(mainW, 0, sideW, totalH);
|
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);
|
m_dimOverlay->setGeometry(0, 0, totalW, totalH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class Simulation;
|
|||||||
class GameWorldView;
|
class GameWorldView;
|
||||||
class HeaderBar;
|
class HeaderBar;
|
||||||
class SelectedBuildingPanel;
|
class SelectedBuildingPanel;
|
||||||
class BuildButtonGrid;
|
class BuildButtonBar;
|
||||||
class BlueprintPanel;
|
class BlueprintPanel;
|
||||||
class ItemIconCache;
|
class ItemIconCache;
|
||||||
class QCloseEvent;
|
class QCloseEvent;
|
||||||
@@ -78,12 +78,12 @@ private:
|
|||||||
VisualsConfig m_visuals;
|
VisualsConfig m_visuals;
|
||||||
Simulation* m_sim;
|
Simulation* m_sim;
|
||||||
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
|
// One per-item icon cache for the whole window (REQ-UI-ITEM-ICON): the header,
|
||||||
// build grid, world view, and recipe dialog all rasterize the same SVGs.
|
// build bar, world view, and recipe dialog all rasterize the same SVGs.
|
||||||
std::unique_ptr<ItemIconCache> m_itemIcons;
|
std::unique_ptr<ItemIconCache> m_itemIcons;
|
||||||
GameWorldView* m_gameWorldView;
|
GameWorldView* m_gameWorldView;
|
||||||
HeaderBar* m_headerBar;
|
HeaderBar* m_headerBar;
|
||||||
SelectedBuildingPanel* m_selectedBuildingPanel;
|
SelectedBuildingPanel* m_selectedBuildingPanel;
|
||||||
BuildButtonGrid* m_buildButtonGrid;
|
BuildButtonBar* m_buildButtonBar;
|
||||||
BlueprintPanel* m_blueprintPanel;
|
BlueprintPanel* m_blueprintPanel;
|
||||||
QWidget* m_sidePanel;
|
QWidget* m_sidePanel;
|
||||||
ModalDimOverlay* m_dimOverlay = nullptr;
|
ModalDimOverlay* m_dimOverlay = nullptr;
|
||||||
|
|||||||
Reference in New Issue
Block a user