From f255224ccda932b32bebed86c295c0eeb023d39f Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Tue, 11 Aug 2026 21:40:36 +0200 Subject: [PATCH] show recipes visually instead of describing them in text --- docs/requirements.md | 38 +++-- src/lib/config/ConfigLoaderRecipes.cpp | 8 - src/lib/config/RecipesConfig.h | 4 - src/lib/sim/ProductionRules.cpp | 27 +++ src/lib/sim/ProductionRules.h | 6 + src/test/ConfigLoaderTest.cpp | 33 ---- src/ui/CMakeLists.txt | 8 +- src/ui/ItemIconCache.cpp | 2 +- src/ui/ItemIconCache.h | 8 +- src/ui/ItemProducers.cpp | 60 +++++++ src/ui/ItemProducers.h | 35 ++++ src/ui/MainWindow.cpp | 8 +- src/ui/OptionButton.cpp | 35 ++++ src/ui/OptionButton.h | 28 ++++ src/ui/RecipeLineRow.cpp | 140 ++++++++++++++++ src/ui/RecipeLineRow.h | 98 +++++++++++ src/ui/RecipeSelectionDialog.cpp | 160 ++++++++++-------- src/ui/RecipeSelectionDialog.h | 28 ++-- src/ui/RecipeTooltip.cpp | 52 ------ src/ui/RecipeTooltip.h | 11 -- src/ui/SchematicChoiceDialog.cpp | 53 ++++-- src/ui/SchematicChoiceDialog.h | 7 +- src/ui/ShipLayoutDialog.cpp | 89 +++++++++- src/ui/ShipLayoutDialog.h | 9 + src/ui/selection/BufferedBuildingContent.cpp | 24 +-- src/ui/selection/BufferedBuildingContent.h | 4 +- src/ui/selection/CMakeLists.txt | 4 +- src/ui/selection/HqContent.cpp | 2 +- src/ui/selection/ItemChip.cpp | 43 ++++- src/ui/selection/ItemChip.h | 31 +++- src/ui/selection/ItemChipRow.cpp | 9 +- src/ui/selection/ItemChipRow.h | 13 +- src/ui/selection/ItemTooltip.cpp | 163 +++++++++++++++++++ src/ui/selection/ItemTooltip.h | 36 ++++ src/ui/selection/RecipeSelectionControl.cpp | 5 +- src/ui/selection/RecipeSummaryRow.cpp | 123 -------------- src/ui/selection/RecipeSummaryRow.h | 52 ------ src/ui/selection/ShipyardContent.cpp | 19 +-- 38 files changed, 1023 insertions(+), 452 deletions(-) create mode 100644 src/ui/ItemProducers.cpp create mode 100644 src/ui/ItemProducers.h create mode 100644 src/ui/OptionButton.cpp create mode 100644 src/ui/OptionButton.h create mode 100644 src/ui/RecipeLineRow.cpp create mode 100644 src/ui/RecipeLineRow.h delete mode 100644 src/ui/RecipeTooltip.cpp delete mode 100644 src/ui/RecipeTooltip.h create mode 100644 src/ui/selection/ItemTooltip.cpp create mode 100644 src/ui/selection/ItemTooltip.h delete mode 100644 src/ui/selection/RecipeSummaryRow.cpp delete mode 100644 src/ui/selection/RecipeSummaryRow.h diff --git a/docs/requirements.md b/docs/requirements.md index bcfc924..a64e268 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -6,8 +6,7 @@ Config files use the TOML format. The following config files drive game paramete - **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, building deconstruction time, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, view pan speeds (slow and fast horizontal pan speed and pan ramp band width), an optional building blocks tooltip string (shown as the header bar's building blocks stock hover tooltip, REQ-UI-BLOCKS-TOOLTIP; omitted when unset), and an optional artifact tooltip string (shown as the header bar's artifact count hover tooltip, REQ-UI-ARTIFACTS-TOOLTIP; omitted when unset). - **buildings.toml** — building block cost and construction time per building type, plus an optional tooltip description string per building type (shown as the build button's hover tooltip, REQ-UI-BUILD-TOOLTIP; omitted when unset). Whether a building type is available from game start or must be unlocked during play is not defined here but in **unlocks.toml** (REQ-LOCK-EXPLICIT): a building type granted by an unlock group starts locked and is hidden from the build menu until its group is awarded (REQ-LOCK-BUILDING). -- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlocked_at_start` (boolean, default false): when true the recipe is available from game start regardless of the implicit item graph — used for base recipes that no schematic's materials reach (such as building blocks; see REQ-LOCK-IMPLICIT). Which assembler recipes must instead be awarded during play (explicitly gated) is defined in **unlocks.toml**, not here (REQ-LOCK-EXPLICIT); every remaining assembler recipe is implicitly unlocked through the item graph (REQ-LOCK-IMPLICIT). Any recipe entry may optionally define `icon` (string): the id of an item whose icon represents the recipe in the recipe-selection dialog (REQ-UI-RECIPE-ICON); when omitted, the recipe's first output item is used. -- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). Whether a ship schematic is available from game start or must be unlocked during play is defined in **unlocks.toml** (REQ-LOCK-EXPLICIT), not here. +- **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlocked_at_start` (boolean, default false): when true the recipe is available from game start regardless of the implicit item graph — used for base recipes that no schematic's materials reach (such as building blocks; see REQ-LOCK-IMPLICIT). Which assembler recipes must instead be awarded during play (explicitly gated) is defined in **unlocks.toml**, not here (REQ-LOCK-EXPLICIT); every remaining assembler recipe is implicitly unlocked through the item graph (REQ-LOCK-IMPLICIT).- **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). Whether a ship schematic is available from game start or must be unlocked during play is defined in **unlocks.toml** (REQ-LOCK-EXPLICIT), not here. - **modules.toml** — per module type: id, surface mask, materials list, production time, fill color, glyph, an optional tooltip description string (shown as the module selection button's hover tooltip, REQ-MOD-UI-MODULE-TOOLTIP; omitted when unset), and an optional capability section and/or stat modifier formulas. Whether a module schematic is available from game start or must be unlocked during play is defined in **unlocks.toml** (REQ-LOCK-EXPLICIT), not here. A module with a capability section (`[module.weapon]`, `[module.salvage]`, or `[module.repair]`) containing base stat formulas is a **capability module** that grants the ship a weapon, salvage bay, or repair tool per instance (see REQ-MOD-CONFIG for the full list of formulas per capability type). A module with only `added_*`/`multiplied_*` formulas is a **passive module** that modifies stats on the ship or on capability module instances (see REQ-MOD-STAT-CALC). - **unlocks.toml** — unlock groups: each `[[unlock]]` entry names a group of ship schematics, module schematics, building types, and/or assembler recipes that are awarded together from a single defence station drop (see Unlock Group Format, REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). Anything not granted by any unlock group is available from game start. - **stations.toml** — HP, damage, range, fire rate, and scrap drop for player and enemy defence stations, defined as formulas of station level. @@ -311,8 +310,8 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro The dialog contains: - **Top**: The ship's layout grid rendered at full scale. Buildable cells are white; non-buildable cells are black. Placed modules are rendered with their `fill_color` and `glyph`. The ghost of the currently selected module is shown at the cursor position when in placement mode. - - **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL). - - **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. Each module button shows the module id and its glyph. + - **Left** (below the grid): The ship stats panel (see REQ-MOD-UI-STATS-PANEL), and beneath it the layout's **build cost**, so the price of a configuration is visible while it is being assembled rather than only once it has been confirmed: the total materials required for the ship — the union of the schematic's base materials and those of every placed module, summed per item type (REQ-MOD-MATERIALS) — drawn as item icons on their colored squares with their amounts (REQ-UI-ITEM-ICON), and beside them the total production time (REQ-MOD-PRODUCTION-TIME). Both update in real time as modules are placed and removed. The cost sits beside the stats panel rather than within it because a build cost belongs to a ship being configured: the same panel serves an existing ship (REQ-UI-SHIP-STATS-PANEL) and the balancing tool, neither of which costs anything. + - **Center** (below the grid): A grid of module selection buttons (one per **unlocked** module type; see REQ-DEF-SCHEMATIC-DROP) plus a "Remove" button. A module button shows the module's name — its `id` under the usual display convention — on its first line, and beneath it what the module costs: the icons of its `materials` items on their colored squares with their amounts (REQ-UI-ITEM-ICON) and the production time it adds, as `+ s` (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME). This is the same form a recipe option button uses to state what it makes (REQ-UI-SELECT-OPTIONS), and it puts the price of a module in front of the player before it is placed. The "Remove" button costs nothing and shows its caption alone. - **Right** (below the grid): The layout blueprint panel (see REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). - **Bottom**: A "Confirm" button and a "Cancel" button. Cancel discards all changes made in this dialog session and closes the dialog. Confirm applies the changes: the shipyard's configured layout is updated, the required materials and cycle time displayed in the selection panel are recalculated, and the ship layout preview is refreshed. @@ -372,9 +371,9 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro Each option in the dialog displays the unlock group's display name — derived from its `id` (same display convention as building, module, and recipe ids) — and the list of items it would grant: its ship, module, building, and assembler-recipe ids (each shown with the same display convention as its respective selection dialog). The artifact option (if present) is displayed as a distinct entry with the name "Artifact". - Each option additionally displays a vertical list of recipe names labeled "Unlocks recipes:", showing which miner and assembler recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the miner recipes and implicitly-gated assembler recipes that are not currently implicitly unlocked but would become so after applying this option's effect. To compute this, all `materials` of the group's granted ship and module schematics are added to the base set per REQ-LOCK-IMPLICIT step 1a, and the output items of the group's granted assembler recipes are added per step 1b, before recomputation. + Each option additionally displays a vertical list of recipe lines labeled "Unlocks recipes:", showing which miner and assembler recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the miner recipes and implicitly-gated assembler recipes that are not currently implicitly unlocked but would become so after applying this option's effect. To compute this, all `materials` of the group's granted ship and module schematics are added to the base set per REQ-LOCK-IMPLICIT step 1a, and the output items of the group's granted assembler recipes are added per step 1b, before recomputation. - Each recipe is listed by its `id` (using the same display convention as the assembler recipe-selection dialog), sorted alphabetically. Hovering a recipe in this list displays the recipe info tooltip described for a recipe in REQ-UI-SELECT-TOOLTIP (the recipe name; the name and quantity of each input item; the completion time; and the name and quantity of the produced output item). If no recipes would be newly unlocked, the list shows "None". + Each recipe is shown as a **recipe line** of the same form the item production tooltip uses (REQ-UI-ITEM-TOOLTIP): the icon of the building that runs it (REQ-UI-BUILD-ICON), the recipe's name — by its `id`, using the same display convention as the assembler recipe-selection dialog — and the recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY). The lines are sorted alphabetically by recipe name. The line says everything there is to say about the recipe, so nothing in this list carries a tooltip, as in the selection dialog (REQ-UI-SELECT-OPTIONS). If no recipes would be newly unlocked, the list shows "None". The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no unlock is applied. Otherwise the selected unlock group is awarded and the dialog closes: every ship, module, building, and assembler recipe the group grants becomes unlocked at once — ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG); building types become available in the build menu (REQ-LOCK-BUILDING); assembler recipes become available in the assembler recipe-selection dialog (subject to REQ-LOCK-UI-RECIPE). The unlock group is removed from the pool permanently (REQ-LOCK-EXPLICIT), and the implicit unlock set is recomputed (REQ-LOCK-IMPLICIT). @@ -490,7 +489,7 @@ The screen is a single column: a header bar across the top and the game world vi Because the contest-zone boundaries shift as the scrollable area grows with each push (REQ-GW-PUSH-EXPAND, REQ-GW-SCROLL-LIMIT), the ramp bands are recomputed from the current contest-zone boundaries. This is a presentation-only concern and does not affect the simulation, consistent with REQ-UI-NO-ZOOM. - REQ-UI-WORLD-ICON: In the game world, a building is drawn with an icon's glyph symbol centered on its footprint, in place of the letter identity glyph. The icon is an SVG loaded from `data/icons/buildings/`; only the icon's glyph is drawn in the world — in a contrasting ink (white over dark fills, dark over light fills) so it stays legible — and its colored chip background is omitted, because the footprint is already filled with the building's `visuals.toml` fill color. This applies to the production buildings (Miner, Smelter, Assembler, Reprocessing Plant, Shipyard, Salvage Bay), the HQ, and the player and enemy defence stations, wherever the identity label appears: operational buildings, construction sites (REQ-UI-CONSTRUCTION-PROGRESS), and the builder-mode and blueprint-placement ghosts. **Belts, splitters, and tunnels are excluded** — they keep their existing tile rendering so their orientation and flow stay readable (a centered icon would obscure direction). Their build-menu buttons still use icons (REQ-UI-BUILD-ICON); in particular the shared Tunnel button's `tunnel_entry.svg` is a build-button icon only, not a world icon. The directional output-port glyphs (REQ-UI-PORT-GLYPH, REQ-UI-PORT-TARGET-GLYPH) are a separate indicator and are unaffected. A building or station with no icon file falls back to its `visuals.toml` text glyph; a type with neither icon nor glyph shows no identity label. A missing icon is not an error, consistent with REQ-UI-BUILD-ICON. -- REQ-UI-ITEM-ICON: In the game world, an item is drawn as its `visuals.toml` colored square (`fill` + `outline`, the square of REQ-GW-TILE-SIZE) carrying its **item icon** on top. The square is drawn for every item, with or without an icon: it is what gives the item contrast against the tile beneath it, and its outline is what separates neighbouring items where they overlap on a belt. The icon is a self-contained, full-color SVG (rendered as-is, unlike the glyph-only building icons of REQ-UI-WORLD-ICON), loaded at runtime from `data/icons/items/` — a sibling of the config directory, read the same way as the building icons (REQ-UI-BUILD-ICON) — one file per item type named after the item's id (e.g. `iron_ore.svg`). The square fills the item's half-tile rect, keeping the size, spacing, and draw-order rules of REQ-GW-TILE-SIZE; the icon is drawn **inset** within that rect so a frame of the square's color stays visible all around it — required because each icon's viewBox is cropped tight to its artwork, so an icon drawn at the full rect would cover the square entirely. This applies wherever an item is drawn: on belts, splitters, and tunnel ends, and while emerging from or sinking into a building port (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE). An item type with no icon file shows the colored square alone; a missing icon is not an error, consistent with REQ-UI-BUILD-ICON. **The colored square travels with the icon into the UI.** Wherever the UI displays an item *as an item* — the recipe-selection dialog's option buttons (REQ-UI-RECIPE-ICON), the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), and the recipe summary's input and output icons (REQ-UI-RECIPE-SUMMARY) — the icon is drawn on that same square, by the same rules: the item's `fill` and `outline` from `visuals.toml`, the icon inset within the square so a frame of the square's color stays visible all around it, and the square alone when the item has no icon file. The square fills the rect the display allots to the icon, as it fills the half-tile rect in the world. One item therefore reads the same in a panel as it does on a belt. The one exception is the inline `building_block` icon that stands in for the word `Blocks` beside a number (REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON, REQ-UI-BUILD-COST, REQ-UI-MULTI-SELECTION, REQ-UI-BLUEPRINT-CARD): there the icon is a decoration on a line of text rather than an item display, and it is drawn bare, without the square. For performance, each item icon is rasterized to a pixmap cached per target pixel size — re-rasterized only when the tile pixel size changes (e.g. on view resize) — rather than re-rendered from vector every frame. +- REQ-UI-ITEM-ICON: In the game world, an item is drawn as its `visuals.toml` colored square (`fill` + `outline`, the square of REQ-GW-TILE-SIZE) carrying its **item icon** on top. The square is drawn for every item, with or without an icon: it is what gives the item contrast against the tile beneath it, and its outline is what separates neighbouring items where they overlap on a belt. The icon is a self-contained, full-color SVG (rendered as-is, unlike the glyph-only building icons of REQ-UI-WORLD-ICON), loaded at runtime from `data/icons/items/` — a sibling of the config directory, read the same way as the building icons (REQ-UI-BUILD-ICON) — one file per item type named after the item's id (e.g. `iron_ore.svg`). The square fills the item's half-tile rect, keeping the size, spacing, and draw-order rules of REQ-GW-TILE-SIZE; the icon is drawn **inset** within that rect so a frame of the square's color stays visible all around it — required because each icon's viewBox is cropped tight to its artwork, so an icon drawn at the full rect would cover the square entirely. This applies wherever an item is drawn: on belts, splitters, and tunnel ends, and while emerging from or sinking into a building port (REQ-MAT-OUTPUT-EMERGE, REQ-MAT-INPUT-INTAKE). An item type with no icon file shows the colored square alone; a missing icon is not an error, consistent with REQ-UI-BUILD-ICON. **The colored square travels with the icon into the UI.** Wherever the UI displays an item *as an item* — the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), the recipe summary's input and output icons (REQ-UI-RECIPE-SUMMARY), and those same icons wherever a recipe is drawn in that form: on the selection dialog's option buttons (REQ-UI-SELECT-OPTIONS), in the item production tooltip (REQ-UI-ITEM-TOOLTIP), and in the unlock-choice dialog's recipe lines (REQ-DEF-SCHEMATIC-DROP) — the icon is drawn on that same square, by the same rules: the item's `fill` and `outline` from `visuals.toml`, the icon inset within the square so a frame of the square's color stays visible all around it, and the square alone when the item has no icon file. The square fills the rect the display allots to the icon, as it fills the half-tile rect in the world. One item therefore reads the same in a panel as it does on a belt. The one exception is the inline `building_block` icon that stands in for the word `Blocks` beside a number (REQ-UI-BLOCKS-ICON, REQ-UI-EXPAND-BUTTON, REQ-UI-BUILD-COST, REQ-UI-MULTI-SELECTION, REQ-UI-BLUEPRINT-CARD): there the icon is a decoration on a line of text rather than an item display, and it is drawn bare, without the square. For performance, each item icon is rasterized to a pixmap cached per target pixel size — re-rasterized only when the tile pixel size changes (e.g. on view resize) — rather than re-rendered from vector every frame. - REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's identity symbol centered on the footprint (same as an operational building) — its icon glyph, or the text glyph as a fallback (REQ-UI-WORLD-ICON). Below the symbol — or centered on the footprint if the building has neither an icon nor a glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes. - REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port. - REQ-UI-PORT-TARGET-GLYPH: While in builder mode (REQ-BLD-BUILDER-MODE), the builder-mode ghost additionally shows, for each of the building's output ports, a directional glyph drawn centered in the port's **target cell** — the cell immediately outside the footprint that the port pushes into, i.e. the cell the surface-mask output-port indicator occupies (see Surface Mask Format). As in REQ-UI-PORT-GLYPH the glyph is a `>` rotated to face the port's exit direction (`>` East, `^` North, `<` West, `v` South), previewing where the port's output will go before placement. This is in addition to the on-tile port glyph of REQ-UI-PORT-GLYPH, and — unlike that indicator — is shown only for the builder-mode ghost, not for operational buildings, construction sites, or the blueprint-placement ghost (REQ-UI-BLUEPRINT-PLACE). A building with multiple output ports (e.g. a splitter) shows one target-cell glyph per port. The target-cell glyph is drawn larger than the on-tile port glyph so it stands out as the flow-direction preview. Exceptions: the Tunnel Entry shows no target-cell glyph, because it receives items (which may arrive from any of its non-mouth edges, REQ-BLD-TUNNEL-ENTRY) rather than emitting into a single adjacent cell; the Shipyard shows none either, because its output port is a ship-spawn point (REQ-SHP-SPAWN-PLAYER) rather than a belt-item output (REQ-MAT-OUTPUT-EMERGE). @@ -580,7 +579,7 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE - **Debris** — several pieces of debris and nothing else. Their remaining scrap sums into one value (REQ-UI-DEBRIS-PANEL). Every other multi-selection falls back to the count summary. In particular a selection mixing a splitter with belts does not aggregate (a splitter carries per-object output filters, which have no aggregate), and neither do several production buildings of one type (per-building buffers and cycle progress have no aggregate). -- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows its symbol and name in the header (REQ-UI-SELECTION-CARD), its current recipe or schematic selection (REQ-UI-SELECT-BUTTON) and recipe summary (REQ-UI-RECIPE-SUMMARY) in the configuration group, and its input and output buffer contents in the runtime group. Each buffered item is shown as an **item chip** bearing that item's icon on its colored square (REQ-UI-ITEM-ICON) and its current count: +- REQ-UI-SINGLE-SELECTION: When one building is selected, the panel shows its symbol and name in the header (REQ-UI-SELECTION-CARD), its current recipe or schematic selection (REQ-UI-SELECT-BUTTON) and recipe summary (REQ-UI-RECIPE-SUMMARY) in the configuration group, and its input and output buffer contents in the runtime group. Each buffered item is shown as an **item chip** bearing that item's icon on its colored square (REQ-UI-ITEM-ICON) and its current count, and hovering a chip shows that item's production tooltip (REQ-UI-ITEM-TOOLTIP): - an **input** chip shows the per-cycle amount below the count (the items consumed per run, e.g. `/ 2 per cycle`), or the count alone when the building has no selected recipe or schematic to give one; - an **output** chip shows the count against the output buffer's capacity as `a / b` (REQ-MAT-OUTPUT-BUFFER), with the item's name below. @@ -594,13 +593,22 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE - REQ-UI-MULTI-SELECT: The player selects multiple objects by box-drag or by Ctrl+clicking individual objects to add or remove them from the selection. Multi-select operates within a single category (REQ-UI-SELECTION-CATEGORIES). A box-drag that covers at least one building selects buildings (any field objects within the box are ignored — buildings win); a box-drag that covers no building but does cover ships, defence stations, or debris selects all of those field objects together (REQ-UI-ENTITY-CLICK-SELECT, REQ-UI-DEBRIS-MULTI-SELECT). - REQ-UI-MULTI-SELECTION: When multiple buildings are selected and the selection does not aggregate (REQ-UI-SELECTION-AGGREGATE), the panel shows a count summary. Its header names the size of the selection as ` buildings` in place of an object name, and carries no symbol and nothing in its right slot. Below it is one row per selected building type — the type's symbol, its name, and the number selected as `x` — one type per row, and no per-building detail. A final row shows the **total building block cost** of the selection, captioned `Total cost` with the value followed by the `building_block` item icon (REQ-UI-BLOCKS-ICON, REQ-UI-ITEM-ICON): the sum of each selected building's placement cost (`buildings.toml [[building]].cost`, per REQ-BLD-COST), counting only player-placeable buildings (buildings with a button in the build button bar); non-player-placeable buildings (the HQ and defence stations) are excluded from the total, consistent with the blueprint total (REQ-UI-BLUEPRINT-CARD). Construction sites count at their building type's full placement cost regardless of construction progress. - REQ-UI-CONFIG-INLINE: Recipe and schematic configuration for a selected building is shown within this panel, in its configuration group (REQ-UI-SELECTION-CARD). Recipe selection (miner, assembler) and schematic selection (shipyard) use the selection button and dialog (REQ-UI-SELECT-BUTTON) rather than an inline control. For shipyards, the panel additionally shows the ship layout preview and "Configure" button below the schematic selection button (REQ-MOD-UI-PREVIEW). -- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selection panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a grid of option buttons, one per selectable option — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Hovering an option button shows the selection info tooltip (REQ-UI-SELECT-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD). -- REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selection panel when a selection is set, displays an info tooltip: - - For a **recipe** (Miner or Assembler): the recipe name; the name and quantity of each input item (no inputs are listed for miner recipes, which consume nothing); the completion time (`duration_seconds`); and the name and quantity of the produced output item. - - For a **ship schematic** (Shipyard): the ship's `display_name`; the name and quantity of each base required material (`[ship.schematic].materials`, excluding any module contributions); the base production time (`[ship.schematic].production_time_seconds`); and "Produces: 1 ". -- REQ-UI-RECIPE-ICON: In the recipe-selection dialog (REQ-UI-SELECT-BUTTON) for a Miner or Assembler, each recipe option button shows the icon of the recipe's produced item **instead of** its name caption (icon-only). The item shown is the recipe's `icon` field if set, otherwise its first output item; the icon is that item's icon on its colored square per REQ-UI-ITEM-ICON. When the item has no icon file, the button shows that colored square alone rather than a name caption, so every option button keeps the same face. The recipe name and details remain available on hover via the selection info tooltip (REQ-UI-SELECT-TOOLTIP). The `(None)` option keeps its text caption. This applies only to recipe options; the Shipyard schematic-selection dialog is unaffected and continues to show ship name captions. +- REQ-UI-SELECT-BUTTON: **Recipe and schematic selection control.** Recipe selection (Miner ore type, Assembler recipe) and schematic selection (Shipyard) are each presented in the selection panel as a single **selection button** whose caption is the name of the currently selected recipe or schematic, or a placeholder ("Select recipe" / "Select schematic") when none is selected. Clicking the button opens a modal **selection dialog** that pauses the game (speed set to 0×; on close, the speed is restored to what it was before the dialog was opened) and that opens centered on the selection panel, as every modal opened from the panel does (REQ-UI-PANEL-MODAL). The dialog contains a vertical list of option buttons, one per selectable option, each describing itself (REQ-UI-SELECT-OPTIONS) — only options that are currently unlocked are shown (REQ-LOCK-UI-RECIPE for recipes, REQ-LOCK-UI-SCHEMATIC for schematics). Neither the option buttons nor the selection button carries a tooltip: an option button states what it makes on its own face, and what the building has selected is drawn beneath the selection button as the recipe summary (REQ-UI-RECIPE-SUMMARY), with the production paths of the items involved reachable by hovering the card's item chips (REQ-UI-ITEM-TOOLTIP). Clicking an option button selects that recipe/schematic, closes the dialog, and updates the selection button's caption in the selection panel. The dialog can be dismissed without changing the current selection (e.g. closing it without clicking an option). Selecting a new recipe or schematic has the same effects as before (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER, REQ-BLD-SHIPYARD). +- REQ-UI-SELECT-OPTIONS: **Option list of the selection dialog.** The selection dialog (REQ-UI-SELECT-BUTTON) lists its options as a **single vertical column** of buttons, one per option, rather than a grid: each button is as wide as the dialog and states what the option does, which needs a line of its own. No option button carries a tooltip — the button face is the whole description, so the player reads every option's inputs, product, and time from the list itself without hovering anything. Should the column be taller than the space the window leaves the dialog — a fully unlocked Assembler offers more options than any window can hold, and a modal is never resized to fit (REQ-UI-PANEL-MODAL) — the list **scrolls** within the dialog rather than the dialog growing past the window. + - A **recipe** option (Miner, Assembler) shows the **recipe name** on its first line and, beneath it, that recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY): each input item's icon on its colored square with its per-cycle amount, an arrow, each output item's icon with its amount, and the cycle time. A miner recipe consumes nothing, so its line begins at the arrow. + - A **ship schematic** option (Shipyard) shows the ship's `display_name` on its first line and, beneath it, the icons and quantities of its base required materials (`[ship.schematic].materials`, excluding any module contributions) with the base production time (`[ship.schematic].production_time_seconds`). A ship is not an item and has no icon of its own, so this line names no output: the button's caption is what it produces. + - The `(None)` option shows its text caption alone. +- REQ-UI-ITEM-TOOLTIP: **Item production tooltip.** Hovering an item chip in the selection panel — an input or output buffer chip (REQ-UI-SINGLE-SELECTION) or the HQ's block stock chip (REQ-UI-HQ-PANEL) — shows a tooltip telling the player where that item comes from. It has a heading and a body: + - the heading is the **hovered item's name**. For an input chip this is the only place the item is named at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION). + - the body is the caption `Produced by` followed by one **recipe line** per unlocked recipe that produces the item. Each line carries the icon of the building that runs that recipe (REQ-UI-BUILD-ICON), the recipe's name, and the recipe drawn as the recipe summary line draws it (REQ-UI-RECIPE-SUMMARY): each input item's icon on its colored square with its per-cycle amount, an arrow, each output item's icon with its amount, and the cycle time. An item may have several producers — an iron ingot is smelted from ore, smelted from scrap, and recovered by reprocessing — and the building icon and recipe name are what tell those lines apart and tell the player which building to place for which path. + + **Only recipes the player can run are listed**, consistent with the rest of the UI hiding what the player cannot make yet. What that means differs by building, because only Miner and Assembler recipes are unlocked individually (REQ-LOCK-UI-RECIPE): those are listed once unlocked, while a Smelter's or Reprocessing Plant's implicit recipes (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are listed once **their building** is unlocked (REQ-LOCK-BUILDING) — there is no sense in naming a path through a plant the player cannot place. Two cases have no recipe line to show, and each says so in place of the list rather than leaving the tooltip bare: + - **An item no recipe produces at all.** Scrap is salvaged from debris (REQ-RES-DEBRIS-DROP) rather than crafted, so its tooltip reads `Salvaged from debris` in place of the `Produced by` caption and lists nothing beneath it. + - **An item whose every producing recipe is still locked.** The caption stays `Produced by`, and the single line beneath it reads `Undiscovered`: the player is told the item is made somehow, without being shown a path they have not unlocked yet. + + The tooltip belongs to item chips only. The selection dialog shows no tooltips at all, its buttons describing themselves (REQ-UI-SELECT-OPTIONS), and the icons of the recipe summary line (REQ-UI-RECIPE-SUMMARY) show none either: they are parts of one line that already describes one recipe, rather than standalone item displays. - REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel's runtime group shows a **"Clear stuck items"** button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. The button acts on every selected tile, which is why a selection of belts and tunnel ends aggregates into one content rather than a count summary (REQ-UI-SELECTION-AGGREGATE). -- REQ-UI-HQ-PANEL: When the HQ is selected, the panel shows the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS) and, beneath it, the **global building blocks stock** — the same value as the header bar's stock display (REQ-UI-BLOCKS-ICON), rendered as an item chip (REQ-UI-SINGLE-SELECTION) carrying the `building_block` icon on its colored square — a chip is an item display, so it takes the square even though the header bar's inline block icon does not (REQ-UI-ITEM-ICON, REQ-UI-BLOCKS-ICON). The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site. +- REQ-UI-HQ-PANEL: When the HQ is selected, the panel shows the HQ's **HP** as a bar labelled `current / maximum` (REQ-HQ-STATS, REQ-UI-HP-BARS) and, beneath it, the **global building blocks stock** — the same value as the header bar's stock display (REQ-UI-BLOCKS-ICON), rendered as an item chip (REQ-UI-SINGLE-SELECTION) carrying the `building_block` icon on its colored square — a chip is an item display, so it takes the square even though the header bar's inline block icon does not (REQ-UI-ITEM-ICON, REQ-UI-BLOCKS-ICON), and hovering it shows the item production tooltip as any other chip does (REQ-UI-ITEM-TOOLTIP). The HP comes first, as it does on every card that has it (REQ-UI-SELECTION-CARD). The HQ has no input or output buffers of its own: building blocks delivered by belt go straight into the global stock (REQ-HQ-BELT-INPUT), and showing that stock on the HQ is what tells the player to route blocks there. The HQ has no configuration group and no status indicator (REQ-UI-SELECTION-STATUS), and it is never a construction site. - REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection. - REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selection panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. Its header (REQ-UI-SELECTION-CARD) carries the schematic's color swatch and display name, with the ship's current behavior in the right slot (REQ-UI-SHIP-BEHAVIOR). The panel always shows all hull stats: HP (current / maximum) as a **bar** with the two values beside its caption, then max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed as label/value rows. In addition, capability module summaries are shown below the hull stats, each as its own outlined row, conditioned on which module types are installed and using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT). - REQ-UI-SHIP-BEHAVIOR: The ship stats panel (REQ-UI-SHIP-STATS-PANEL) additionally displays the selected ship's **current behavior** in its header's right slot (REQ-UI-SELECTION-CARD) — a single label naming the top-priority behavior currently governing the ship's navigation, as resolved by the fixed-priority behavior arbitration. Only the winning behavior is named; lower-priority behaviors that are suppressed are not shown, and neither are the salvage/repair cycles that run regardless of the active behavior (REQ-SHP-SALVAGE, REQ-SHP-REPAIR). The label updates live as the ship's behavior changes, and it is always shown (independent of debug draw mode, unlike the threat-cost line of REQ-UI-SHIP-STATS-PANEL). This applies to both player and enemy ships (REQ-UI-ENTITY-CLICK-SELECT); enemy ships only ever show **Engaging** or **Advancing**. The behavior labels (all wrapped in `tr()`) are: @@ -626,7 +634,7 @@ The panel shows exactly one **content** at a time, picked from the catalog in RE - 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-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 item production tooltip of the selection panel's item chips (REQ-UI-ITEM-TOOLTIP); the recipe/schematic selection dialog has no tooltip at all (REQ-UI-SELECT-OPTIONS). The Deconstruct button (REQ-UI-DECONSTRUCT-BUTTON) is not a building type and so has no config-defined tooltip; it instead shows its own refund tooltip defined in REQ-UI-DECONSTRUCT-BUTTON. - REQ-UI-BUILD-DISABLED: Buttons for buildings the player cannot currently afford are shown as disabled. A disabled button's icon (REQ-UI-BUILD-ICON) is rendered in a greyed variant, with its colored chip background recolored grey while the white glyph is retained. - REQ-UI-DECONSTRUCT-BUTTON: A dedicated **Deconstruct** button is shown in the build button bar (REQ-UI-BUILD-BAR), as the last entry of the row and **visually separated** from the building-type buttons by a gap (not a divider line), because it toggles a mode rather than selecting a building type. Its face follows REQ-UI-BUILD-COST with two differences: its hotkey badge reads `Q`, and because it has no building block cost it shows its **Deconstruct** name as a text caption where the building-type buttons show their cost — so it is the one labelled button in the bar. It is therefore wider than the building-type buttons, which share a uniform width. Clicking it toggles deconstruct mode on and off, equivalent to the Q deconstruct toggle (REQ-UI-HOTKEYS). The button is shown in a visually active/pressed state while deconstruct mode is active. The button shows a hover tooltip stating the deconstruction refund (REQ-BLD-DECONSTRUCT): that deconstructing a fully-built building returns `world.toml [world].refund_percentage` percent of its building block cost once deconstruction completes, and that a construction site removed before it finishes building is refunded in full. When `refund_percentage` is 100% both cases yield the same refund, and the tooltip is simplified to state the single refund percentage without distinguishing the two cases. Unlike the building-type button tooltips (REQ-UI-BUILD-TOOLTIP), this tooltip is not config-defined text but is composed from the refund percentage. diff --git a/src/lib/config/ConfigLoaderRecipes.cpp b/src/lib/config/ConfigLoaderRecipes.cpp index 29ea3a3..fda1aa2 100644 --- a/src/lib/config/ConfigLoaderRecipes.cpp +++ b/src/lib/config/ConfigLoaderRecipes.cpp @@ -94,14 +94,6 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path) const toml::array& outputs = utility::requireArray(mt["outputs"], file, elemPath + ".outputs"); def.outputs = parseRecipeOutputs(outputs, file, elemPath + ".outputs"); - // Optional icon item id (REQ-UI-RECIPE-ICON); defaults to the first output - // in the UI when unset. Not validated against known items here — a missing - // icon is not an error (REQ-UI-ITEM-ICON). - if (mt.contains("icon")) - { - def.icon = utility::requireString(mt["icon"], file, elemPath + ".icon"); - } - cfg.recipes.push_back(std::move(def)); } diff --git a/src/lib/config/RecipesConfig.h b/src/lib/config/RecipesConfig.h index 08fdcc1..9bda9b8 100644 --- a/src/lib/config/RecipesConfig.h +++ b/src/lib/config/RecipesConfig.h @@ -32,10 +32,6 @@ struct RecipeDef std::vector inputs; std::vector outputs; double durationSeconds; - // Optional id of the item whose icon represents this recipe in the recipe- - // selection dialog (REQ-UI-RECIPE-ICON). When unset, the first output item is - // used. A missing icon file for that item is not an error (REQ-UI-ITEM-ICON). - std::optional icon; // Assembler only. When true, this recipe is available from game start // regardless of the implicit item graph — used for base recipes that no // schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT. diff --git a/src/lib/sim/ProductionRules.cpp b/src/lib/sim/ProductionRules.cpp index 4812084..7260c76 100644 --- a/src/lib/sim/ProductionRules.cpp +++ b/src/lib/sim/ProductionRules.cpp @@ -81,6 +81,33 @@ computeShipyardRequiredMaterials(const GameConfig& config, } return requiredMaterials; } + +double computeShipyardProductionTimeSeconds( + const GameConfig& config, const std::string& recipeId, + const std::optional& shipLayout) +{ + const ShipDef* shipDef = config.ships.findShipDef(recipeId); + if (!shipDef) + { + return 0.0; + } + + double seconds = shipDef->schematic.productionTimeSeconds; + if (shipLayout.has_value()) + { + for (const PlacedModule& pm : shipLayout->placedModules) + { + const ModuleDef* modDef = config.modules.findModuleDef(pm.moduleId); + if (!modDef) + { + continue; + } + seconds += modDef->productionTimeSeconds; + } + } + return seconds; +} + bool hasInputsToStart(const GameConfig& config, const Building& b) { if (b.type == BuildingType::Shipyard) diff --git a/src/lib/sim/ProductionRules.h b/src/lib/sim/ProductionRules.h index 78e69c1..8be66f2 100644 --- a/src/lib/sim/ProductionRules.h +++ b/src/lib/sim/ProductionRules.h @@ -44,6 +44,12 @@ std::map computeShipyardRequiredMaterials( const GameConfig& config, const std::string& recipeId, const std::optional& shipLayout); +// The ship's base production time plus that of every module in the configured layout +// (REQ-MOD-PRODUCTION-TIME), over a stored configuration as above. +double computeShipyardProductionTimeSeconds( + const GameConfig& config, const std::string& recipeId, + const std::optional& shipLayout); + // True when a production cycle could start right now, ignoring output-buffer space. bool hasInputsToStart(const GameConfig& config, const Building& b); diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index ab3b875..bfdf4bf 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -390,39 +390,6 @@ duration_seconds = 1.0 std::runtime_error); } -TEST_CASE("Optional recipe icon field parses; absent leaves it unset", "[config]") -{ - TempConfigDir dir; - writeFile(dir.path() / "recipes.toml", R"( -[[recipe]] -id = "with_icon" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 1}] -outputs = [{item = "steel_plate", amount = 1}] -duration_seconds = 1.0 -icon = "hardened_steel" - -[[recipe]] -id = "without_icon" -building = "assembler" -inputs = [{item = "iron_ingot", amount = 1}] -outputs = [{item = "copper_wire", amount = 1}] -duration_seconds = 1.0 -)"); - - const RecipesConfig cfg = - ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()); - - const RecipeDef& withIcon = cfg.recipes.at(0); - REQUIRE(withIcon.id == "with_icon"); - REQUIRE(withIcon.icon.has_value()); - REQUIRE(*withIcon.icon == "hardened_steel"); - - const RecipeDef& withoutIcon = cfg.recipes.at(1); - REQUIRE(withoutIcon.id == "without_icon"); - REQUIRE_FALSE(withoutIcon.icon.has_value()); -} - // --- unlock_requires (REQ-LOCK-PREREQ) ------------------------------------ namespace diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index b16063d..7ea9040 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -25,7 +25,9 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h - ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h + ${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.h ${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.h @@ -59,7 +61,9 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/RecipeLineRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/OptionButton.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ItemProducers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingIconCache.cpp ${CMAKE_CURRENT_SOURCE_DIR}/IconCaption.cpp diff --git a/src/ui/ItemIconCache.cpp b/src/ui/ItemIconCache.cpp index 7ae6b41..d7e6991 100644 --- a/src/ui/ItemIconCache.cpp +++ b/src/ui/ItemIconCache.cpp @@ -114,7 +114,7 @@ QPixmap ItemIconCache::getPixmap(const std::string& cacheKey, const std::string& if (withSquare) { // Null unless the item has something to draw -- a square, an icon, or both -- - // so a caller with nothing to show can fall back to text (REQ-UI-RECIPE-ICON). + // so a caller with nothing to show can fall back to text (REQ-UI-ITEM-ICON). const bool hasSquare = m_visuals != nullptr && m_visuals->items.find(itemId) != m_visuals->items.end(); if (hasSquare || hasIcon(itemId)) diff --git a/src/ui/ItemIconCache.h b/src/ui/ItemIconCache.h index 6f2dc5a..6552d93 100644 --- a/src/ui/ItemIconCache.h +++ b/src/ui/ItemIconCache.h @@ -18,9 +18,11 @@ struct VisualsConfig; // // The square is the item's `fill` and `outline` from visuals.toml with the icon inset // within it, and it backs the icon both in the game world and wherever the UI displays -// an item as an item: the recipe-selection dialog's option buttons (REQ-UI-RECIPE-ICON), -// the selection panel's item chips (REQ-UI-SINGLE-SELECTION, REQ-UI-HQ-PANEL), and the -// recipe summary (REQ-UI-RECIPE-SUMMARY). Defined here once rather than per widget, in +// an item as an item: the selection panel's item chips (REQ-UI-SINGLE-SELECTION, +// REQ-UI-HQ-PANEL), and every recipe drawn as a line -- the recipe summary, the +// selection dialog's option buttons, the item production tooltip +// (REQ-UI-RECIPE-SUMMARY, REQ-UI-SELECT-OPTIONS, REQ-UI-ITEM-TOOLTIP). Defined here +// once rather than per widget, in // two forms: paintItem() for the world's fractional geometry, getSquarePixmap() for // widgets that want a ready-made pixmap. // diff --git a/src/ui/ItemProducers.cpp b/src/ui/ItemProducers.cpp new file mode 100644 index 0000000..7fc060e --- /dev/null +++ b/src/ui/ItemProducers.cpp @@ -0,0 +1,60 @@ +#include "ItemProducers.h" + +#include "GameConfig.h" +#include "RecipesConfig.h" +#include "Simulation.h" + +namespace +{ + +bool producesItem(const RecipeDef& recipe, const std::string& itemId) +{ + for (const RecipeOutput& output : recipe.outputs) + { + if (output.item == itemId) + { + return true; + } + } + return false; +} + +bool isAvailable(const RecipeDef& recipe, const Simulation& sim) +{ + if (recipe.building == BuildingType::Miner + || recipe.building == BuildingType::Assembler) + { + return sim.isRecipeUnlocked(recipe.id); + } + return sim.isBuildingUnlocked(recipe.building); +} + +} // namespace + +ItemProduction findItemProduction(const std::string& itemId, const Simulation& sim, + const GameConfig& config) +{ + ItemProduction result; + + bool anyProducer = false; + for (const RecipeDef& recipe : config.recipes.recipes) + { + if (!producesItem(recipe, itemId)) { continue; } + + anyProducer = true; + if (isAvailable(recipe, sim)) + { + result.recipes.push_back(&recipe); + } + } + + if (!result.recipes.empty()) + { + result.origin = ItemOrigin::Produced; + } + else + { + result.origin = anyProducer ? ItemOrigin::Undiscovered : ItemOrigin::Salvaged; + } + return result; +} diff --git a/src/ui/ItemProducers.h b/src/ui/ItemProducers.h new file mode 100644 index 0000000..66c3277 --- /dev/null +++ b/src/ui/ItemProducers.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +struct GameConfig; +struct RecipeDef; +class Simulation; + +// Where an item comes from, as far as the player has discovered (REQ-UI-ITEM-TOOLTIP). +enum class ItemOrigin +{ + Produced, // at least one recipe the player can run makes it + Undiscovered, // recipes make it, but none of them is available yet + Salvaged, // no recipe makes it at all -- scrap, which comes from debris +}; + +struct ItemProduction +{ + ItemOrigin origin = ItemOrigin::Salvaged; + // The available producers, in config order. Empty unless origin is Produced. + std::vector recipes; +}; + +// Every way the player can currently produce the given item, for the tooltip that +// explains an item chip (REQ-UI-ITEM-TOOLTIP). +// +// What counts as available differs by building type, because only Miner and Assembler +// recipes are individually unlocked (REQ-LOCK-UI-RECIPE): those are filtered by the +// unlock state, while a Smelter's or Reprocessing Plant's implicit recipes +// (REQ-BLD-SMELTER, REQ-BLD-REPROCESSING) are filtered by whether their building is +// unlocked yet (REQ-LOCK-BUILDING) -- there is no sense in naming a path through a +// plant the player cannot place. +ItemProduction findItemProduction(const std::string& itemId, const Simulation& sim, + const GameConfig& config); diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3f736f6..e8d27b4 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -224,7 +224,8 @@ void MainWindow::handleEvent(std::shared_ptrchoices, m_sim->getConfig().recipes, this); + SchematicChoiceDialog dialog(event->choices, m_sim->getConfig().recipes, + m_itemIcons.get(), m_buildingIcons.get(), this); dialog.exec(); std::shared_ptr command = @@ -317,7 +318,7 @@ void MainWindow::openShipLayoutDialog(BuildingId shipyardId, m_layoutBlueprints, std::move(unlockedModuleIds), m_gameWorldView->isDebugDrawEnabled(), - this); + m_itemIcons.get(), this); // Opened from the panel's "Configure" button (REQ-MOD-UI-PREVIEW) or straight after // a schematic change (REQ-MOD-UI-AUTO-DIALOG), so it opens on the panel either way. placeOnSelectionPanel(dialog); @@ -427,7 +428,8 @@ void MainWindow::handleEvent(std::shared_ptr +#include +#include + +OptionButton::OptionButton(QWidget* parent) + : QPushButton(parent) +{ +} + +QSize OptionButton::sizeHint() const +{ + if (layout() == nullptr) + { + return QPushButton::sizeHint(); + } + return sizeForFace(layout()->sizeHint()); +} + +QSize OptionButton::minimumSizeHint() const +{ + if (layout() == nullptr) + { + return QPushButton::minimumSizeHint(); + } + return sizeForFace(layout()->minimumSize()); +} + +QSize OptionButton::sizeForFace(const QSize& faceSize) const +{ + QStyleOptionButton option; + initStyleOption(&option); + return style()->sizeFromContents(QStyle::CT_PushButton, &option, faceSize, this); +} diff --git a/src/ui/OptionButton.h b/src/ui/OptionButton.h new file mode 100644 index 0000000..2127a67 --- /dev/null +++ b/src/ui/OptionButton.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +// A push button whose face is built of widgets rather than of a caption: an option's +// name over the recipe line stating what it makes (REQ-UI-SELECT-OPTIONS), or a module's +// name over what it costs (REQ-MOD-UI-DIALOG). +// +// It exists only to be measured correctly. QPushButton computes its size hint from its +// text and icon and ignores a layout set on it, so a button carrying a face of widgets +// would be sized as if it were empty and clip everything in it. This asks the layout +// instead, and then lets the style add what the button's own frame needs. +class OptionButton : public QPushButton +{ + Q_OBJECT + +public: + explicit OptionButton(QWidget* parent = nullptr); + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +private: + // The style's button size for the given face size, or the plain QPushButton hint + // when there is no face to measure. + QSize sizeForFace(const QSize& faceSize) const; +}; diff --git a/src/ui/RecipeLineRow.cpp b/src/ui/RecipeLineRow.cpp new file mode 100644 index 0000000..8acae99 --- /dev/null +++ b/src/ui/RecipeLineRow.cpp @@ -0,0 +1,140 @@ +#include "RecipeLineRow.h" + +#include +#include +#include +#include + +#include "BuildingIconCache.h" +#include "ItemIconCache.h" + +namespace +{ + +// Size the items' colored squares and the building's chip are drawn at on a line, in +// device-independent pixels. Larger than the artwork they carry, which the square +// insets (REQ-UI-ITEM-ICON). +const int kIconSizePx = 18; + +// Adds a freshly built label to the line and shows it. +// +// The show is what makes it count: a widget created under an already-visible parent +// starts hidden, and a layout treats a hidden item as empty, so an unshown label would +// add nothing to the size hint the surrounding widget measures itself against as soon +// as this returns. +void addAndShow(QHBoxLayout* layout, QLabel* label) +{ + layout->addWidget(label); + label->show(); +} + +} // namespace + + +RecipeLineRow::RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, + QWidget* parent) + : QWidget(parent) + , m_itemIcons(itemIcons) + , m_buildingIcons(buildingIcons) +{ + m_layout = new QHBoxLayout(this); + m_layout->setContentsMargins(0, 0, 0, 0); + m_layout->setSpacing(4); + + hide(); +} + +void RecipeLineRow::setLine(const Spec& spec) +{ + if (spec.isEmpty()) + { + // Forgotten as well as hidden, so re-selecting the same recipe later is seen as + // a change and shows the row again. + m_spec = Spec(); + hide(); + return; + } + if (spec == m_spec) + { + return; + } + + m_spec = spec; + rebuild(spec); + show(); +} + +void RecipeLineRow::rebuild(const Spec& spec) +{ + while (QLayoutItem* item = m_layout->takeAt(0)) + { + if (item->widget()) + { + item->widget()->deleteLater(); + } + delete item; + } + + // The building the recipe runs in, where the line has to distinguish one producer + // from another (REQ-UI-ITEM-TOOLTIP). A building with no chip file leaves the icon + // off, as everywhere else (REQ-UI-BUILD-ICON). + if (spec.building.has_value() && m_buildingIcons != nullptr) + { + const QPixmap chip = + m_buildingIcons->getChip(buildingTypeId(*spec.building), kIconSizePx); + if (!chip.isNull()) + { + QLabel* chipLabel = new QLabel(this); + chipLabel->setPixmap(chip); + addAndShow(m_layout, chipLabel); + } + } + if (!spec.name.isEmpty()) + { + addAndShow(m_layout, new QLabel(spec.name, this)); + } + + addAmounts(spec.inputs); + if (!spec.inputs.empty() && !spec.outputs.empty()) + { + const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW + addAndShow(m_layout, new QLabel(QString(rightArrow), this)); + } + addAmounts(spec.outputs); + + if (spec.durationSeconds.has_value() && *spec.durationSeconds > 0.0) + { + const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT + const QString time = spec.durationIsAddition + ? tr("+%1 s").arg(*spec.durationSeconds, 0, 'f', 1) + : tr("%1 s").arg(*spec.durationSeconds, 0, 'f', 1); + addAndShow(m_layout, new QLabel( + QStringLiteral("%1 %2").arg(middleDot).arg(time), this)); + } + m_layout->addStretch(1); +} + +void RecipeLineRow::addAmounts(const std::vector& amounts) +{ + for (const Amount& entry : amounts) + { + // The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file + // is not an error: the square stands alone then, and only an item with no square + // either falls back to its id in text. + const QPixmap icon = (m_itemIcons != nullptr) + ? m_itemIcons->getSquarePixmap(entry.itemId, kIconSizePx) + : QPixmap(); + if (!icon.isNull()) + { + QLabel* iconLabel = new QLabel(this); + iconLabel->setPixmap(icon); + addAndShow(m_layout, iconLabel); + } + else + { + addAndShow(m_layout, + new QLabel(QString::fromStdString(entry.itemId), this)); + } + addAndShow(m_layout, new QLabel(QString::number(entry.amount), this)); + } +} diff --git a/src/ui/RecipeLineRow.h b/src/ui/RecipeLineRow.h new file mode 100644 index 0000000..5457a9e --- /dev/null +++ b/src/ui/RecipeLineRow.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "BuildingType.h" + +class BuildingIconCache; +class ItemIconCache; +class QHBoxLayout; + +// One recipe drawn on a line: what it consumes, an arrow, what it produces, and how +// long a cycle takes (REQ-UI-RECIPE-SUMMARY). Every place the UI states what something +// makes draws this same line, so a recipe reads alike wherever it is shown: +// +// * beneath the selection button, for the recipe a building is running +// (REQ-UI-RECIPE-SUMMARY); +// * on a selection dialog's option buttons, under the option's name +// (REQ-UI-SELECT-OPTIONS); +// * in an item's production tooltip and in the unlock-choice dialog, there led by the +// icon of the building that runs the recipe and its name +// (REQ-UI-ITEM-TOOLTIP, REQ-DEF-SCHEMATIC-DROP); +// * on a module button and in the layout dialog's build cost, where there is a price +// and a time but nothing produced (REQ-MOD-UI-DIALOG, REQ-MOD-UI-STATS-PANEL). +class RecipeLineRow : public QWidget +{ + Q_OBJECT + +public: + // One item and how many of it the line states. + struct Amount + { + std::string itemId; + int amount = 0; + + bool operator==(const Amount& other) const + { + return itemId == other.itemId && amount == other.amount; + } + }; + + // What the line says. Everything but the inputs is optional, which is what lets the + // one widget serve the summary, the option buttons, the tooltip lines and the cost + // lines. + struct Spec + { + // Drawn as a leading chip when the line has to say which building runs the + // recipe -- an item may have several producers (REQ-UI-ITEM-TOOLTIP). Unset + // wherever the surrounding widget already establishes the building. + std::optional building; + // Leading recipe name, for the same reason. Empty where the name is the + // caption above the line instead. + QString name; + std::vector inputs; + // Empty for a line that produces no item of its own: a ship schematic, or a + // module's price. No arrow is drawn then. + std::vector outputs; + std::optional durationSeconds; + // True where the time is added to something else rather than being a cycle of + // its own, and so reads "+3.0 s" (REQ-MOD-UI-DIALOG). + bool durationIsAddition = false; + + bool operator==(const Spec& other) const + { + return building == other.building && name == other.name + && inputs == other.inputs && outputs == other.outputs + && durationSeconds == other.durationSeconds + && durationIsAddition == other.durationIsAddition; + } + + bool isEmpty() const { return inputs.empty() && outputs.empty(); } + }; + + // Both caches may be null, which leaves the icons off: an item with no square and + // no icon falls back to its id, and a building with no chip to its name alone. + // Neither is owned. + RecipeLineRow(ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, + QWidget* parent = nullptr); + + // Hides the row when the spec has nothing to state, which is how a building with no + // recipe shows no summary at all (REQ-UI-RECIPE-SUMMARY). + void setLine(const Spec& spec); + +private: + void rebuild(const Spec& spec); + void addAmounts(const std::vector& amounts); + + ItemIconCache* m_itemIcons; + BuildingIconCache* m_buildingIcons; + QHBoxLayout* m_layout; + // What the row currently shows, so a refresh at tick rate rebuilds it only when the + // recipe actually changed. + Spec m_spec; +}; diff --git a/src/ui/RecipeSelectionDialog.cpp b/src/ui/RecipeSelectionDialog.cpp index 096a5f4..9c996fa 100644 --- a/src/ui/RecipeSelectionDialog.cpp +++ b/src/ui/RecipeSelectionDialog.cpp @@ -2,60 +2,46 @@ #include -#include -#include -#include +#include +#include +#include #include -#include -#include +#include #include #include "Building.h" #include "BuildingType.h" #include "DisplayName.h" #include "GameConfig.h" -#include "ItemIconCache.h" +#include "OptionButton.h" #include "RecipesConfig.h" -#include "RecipeTooltip.h" #include "ShipsConfig.h" #include "Simulation.h" namespace { -QString itemLine(const std::string& itemId, int amount) +std::vector toAmounts( + const std::vector& ingredients) { - return QStringLiteral(" ") - + QString::fromStdString(toDisplayName(itemId)) - + QStringLiteral(" x ") + QString::number(amount); + std::vector amounts; + amounts.reserve(ingredients.size()); + for (const RecipeIngredient& ingredient : ingredients) + { + amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount }); + } + return amounts; } -QString shipTooltip(const ShipDef& def) +std::vector toAmounts(const std::vector& outputs) { - const QString name = QString::fromStdString(toDisplayName(def.id)); - - QStringList lines; - lines << QObject::tr("Ship: %1").arg(name); - - if (def.schematic.materials.empty()) + std::vector amounts; + amounts.reserve(outputs.size()); + for (const RecipeOutput& output : outputs) { - lines << QObject::tr("Materials: none"); + amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount }); } - else - { - lines << QObject::tr("Materials:"); - for (const RecipeIngredient& material : def.schematic.materials) - { - lines << itemLine(material.item, material.amount); - } - lines << QObject::tr(" + installed modules"); - } - - lines << QObject::tr("Completion time: %1 s") - .arg(def.schematic.productionTimeSeconds); - lines << QObject::tr("Produces: 1 %1").arg(name); - - return lines.join('\n'); + return amounts; } } // namespace @@ -64,16 +50,24 @@ std::vector buildRecipeSelectionOptions( BuildingType type, Simulation& sim, const GameConfig& config) { std::vector options; - options.push_back({std::string(), QObject::tr("(None)"), QString()}); + options.push_back({std::string(), QObject::tr("(None)"), RecipeLineRow::Spec()}); if (type == BuildingType::Shipyard) { for (const ShipDef& def : config.ships.ships) { if (!sim.isSchematicUnlocked(def.id)) { continue; } + + // The base materials and base production time, excluding the modules the + // player has not placed yet (REQ-UI-SELECT-OPTIONS). No output item: a ship + // has no icon, so the caption is what the option produces. + RecipeLineRow::Spec line; + line.inputs = toAmounts(def.schematic.materials); + line.durationSeconds = def.schematic.productionTimeSeconds; + options.push_back({def.id, QString::fromStdString(toDisplayName(def.id)), - shipTooltip(def)}); + line}); } } else @@ -87,16 +81,15 @@ std::vector buildRecipeSelectionOptions( { continue; } - // Icon shown on the option button (REQ-UI-RECIPE-ICON): the recipe's - // configured icon item, else its first output item. - const std::string iconItemId = recipe.icon - ? *recipe.icon - : (recipe.outputs.empty() ? std::string() - : recipe.outputs.front().item); + + RecipeLineRow::Spec line; + line.inputs = toAmounts(recipe.inputs); + line.outputs = toAmounts(recipe.outputs); + line.durationSeconds = recipe.durationSeconds; + options.push_back({recipe.id, QString::fromStdString(toDisplayName(recipe.id)), - buildRecipeTooltip(recipe), - iconItemId}); + line}); } } @@ -105,51 +98,77 @@ std::vector buildRecipeSelectionOptions( namespace { - // On-screen size of a recipe option button's item icon (REQ-UI-RECIPE-ICON). - const QSize kOptionIconSize(32, 32); + // Height the option list is allowed to take, as the dialog's parent window's height + // less what the window margins and the dialog's own frame need. Fully unlocked, an + // Assembler offers more options than any window can hold, and the modal is never + // resized to fit (REQ-UI-PANEL-MODAL) -- so the list scrolls instead. + const int kListHeightReservePx = 80; + + // Fallback cap for a dialog built without a parent window to measure. + const int kFallbackListHeightPx = 600; } RecipeSelectionDialog::RecipeSelectionDialog( const std::vector& options, - const QString& title, ItemIconCache* itemIcons, QWidget* parent) + const QString& title, ItemIconCache* itemIcons, BuildingIconCache* buildingIcons, + QWidget* parent) : QDialog(parent) { setWindowTitle(title); setModal(true); QVBoxLayout* mainLayout = new QVBoxLayout(this); - QGridLayout* grid = new QGridLayout(); - mainLayout->addLayout(grid); - const int columnCount = 2; + // One vertical column of buttons, each stating what it makes (REQ-UI-SELECT-OPTIONS). + QScrollArea* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setSizeAdjustPolicy(QAbstractScrollArea::AdjustToContents); + scrollArea->setMaximumHeight(parent != nullptr + ? qMax(kListHeightReservePx, parent->height() - kListHeightReservePx) + : kFallbackListHeightPx); + mainLayout->addWidget(scrollArea); + + QWidget* list = new QWidget(scrollArea); + QVBoxLayout* listLayout = new QVBoxLayout(list); + listLayout->setContentsMargins(0, 0, 0, 0); + listLayout->setSpacing(4); + for (int i = 0; i < static_cast(options.size()); ++i) { const RecipeSelectionOption& option = options[static_cast(i)]; - QPushButton* button = new QPushButton(this); - // Icon-only for a recipe option: the produced item's icon on its colored square, - // or the square alone when the item has no icon file (REQ-UI-RECIPE-ICON). The - // name stays reachable via the tooltip. Options carrying no item at all -- the - // "(None)" entry and the Shipyard's schematics -- keep their text caption, as - // does an item with neither square nor icon to show. - const QPixmap icon = option.iconItemId.empty() - ? QPixmap() - : itemIcons->getSquarePixmap(option.iconItemId, kOptionIconSize.width()); - if (!icon.isNull()) + // An OptionButton so the face of widgets below is what sizes the button + // (REQ-UI-SELECT-OPTIONS). + OptionButton* button = new OptionButton(list); + if (option.line.isEmpty()) { - button->setIcon(QIcon(icon)); - button->setIconSize(kOptionIconSize); + // The "(None)" option makes nothing and is a caption alone + // (REQ-UI-SELECT-OPTIONS). + button->setText(option.caption); } else { - button->setText(option.caption); + // The name over the recipe it stands for. Both are transparent to the mouse + // so a click anywhere on the face still reaches the button beneath them. + QVBoxLayout* face = new QVBoxLayout(button); + face->setContentsMargins(8, 6, 8, 6); + face->setSpacing(2); + + QLabel* nameLabel = new QLabel(option.caption, button); + QFont nameFont = nameLabel->font(); + nameFont.setBold(true); + nameLabel->setFont(nameFont); + nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); + face->addWidget(nameLabel); + + RecipeLineRow* line = new RecipeLineRow(itemIcons, buildingIcons, button); + line->setAttribute(Qt::WA_TransparentForMouseEvents, true); + face->addWidget(line); + line->setLine(option.line); } - if (!option.tooltip.isEmpty()) - { - button->setToolTip(option.tooltip); - } - button->setMinimumHeight(40); - grid->addWidget(button, i / columnCount, i % columnCount); + listLayout->addWidget(button); m_optionIds.push_back(option.id); const int index = i; @@ -158,6 +177,9 @@ RecipeSelectionDialog::RecipeSelectionDialog( onOptionClicked(index); }); } + listLayout->addStretch(1); + + scrollArea->setWidget(list); } std::optional RecipeSelectionDialog::getChosenId() const diff --git a/src/ui/RecipeSelectionDialog.h b/src/ui/RecipeSelectionDialog.h index 66b9243..897d3f6 100644 --- a/src/ui/RecipeSelectionDialog.h +++ b/src/ui/RecipeSelectionDialog.h @@ -8,11 +8,13 @@ #include #include "BuildingType.h" +#include "RecipeLineRow.h" struct GameConfig; +class BuildingIconCache; +class ItemIconCache; class Simulation; class QPushButton; -class ItemIconCache; // One selectable entry in the recipe/schematic selection dialog // (REQ-UI-SELECT-BUTTON). The "(None)" entry uses an empty id. @@ -20,23 +22,23 @@ struct RecipeSelectionOption { std::string id; QString caption; - QString tooltip; - // Id of the item whose icon is shown on the option button (REQ-UI-RECIPE-ICON); - // empty for the "(None)" option and for Shipyard schematics, which keep their - // caption. When set but no icon file exists, the button falls back to the caption. - std::string iconItemId; + // What the option makes, drawn beneath its caption on the button + // (REQ-UI-SELECT-OPTIONS). Empty for the "(None)" option, which is a caption alone. + // A ship schematic fills the inputs and the time and leaves the outputs empty: a + // ship is not an item and has no icon, so the caption is what it produces. + RecipeLineRow::Spec line; }; // Builds the lock-aware list of selectable options for a production building -// of the given type, with display captions and info tooltips -// (REQ-UI-SELECT-BUTTON, REQ-UI-SELECT-TOOLTIP). The first entry is always a +// of the given type, with display captions and recipe lines +// (REQ-UI-SELECT-BUTTON, REQ-UI-SELECT-OPTIONS). The first entry is always a // "(None)" option with an empty id. Shared by the selection dialog and the -// panel selection button so both render identical captions and tooltips, and +// panel selection button so both render identical captions, and // usable for construction sites which have no Building yet (REQ-BLD-SITE-CONFIG). std::vector buildRecipeSelectionOptions( BuildingType type, Simulation& sim, const GameConfig& config); -// Modal dialog showing a grid of option buttons (REQ-UI-SELECT-BUTTON). The +// Modal dialog listing the options in one vertical column (REQ-UI-SELECT-OPTIONS). The // game is paused by the caller while it is open. Clicking an option selects it // and closes the dialog; dismissing it (close/Esc) leaves no choice. class RecipeSelectionDialog : public QDialog @@ -44,12 +46,10 @@ class RecipeSelectionDialog : public QDialog Q_OBJECT public: - // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); used to - // render recipe options icon-only (REQ-UI-RECIPE-ICON). Options whose item has - // no icon file fall back to their caption text. Not owned. + // The icon caches draw each option's recipe line (REQ-UI-ITEM-ICON). Not owned. RecipeSelectionDialog(const std::vector& options, const QString& title, ItemIconCache* itemIcons, - QWidget* parent = nullptr); + BuildingIconCache* buildingIcons, QWidget* parent = nullptr); std::optional getChosenId() const; diff --git a/src/ui/RecipeTooltip.cpp b/src/ui/RecipeTooltip.cpp deleted file mode 100644 index a5761b6..0000000 --- a/src/ui/RecipeTooltip.cpp +++ /dev/null @@ -1,52 +0,0 @@ -#include "RecipeTooltip.h" - -#include -#include - -#include "DisplayName.h" -#include "RecipesConfig.h" - -namespace -{ - -QString itemLine(const std::string& itemId, int amount) -{ - return QStringLiteral(" ") - + QString::fromStdString(toDisplayName(itemId)) - + QStringLiteral(" x ") + QString::number(amount); -} - -} // namespace - -QString buildRecipeTooltip(const RecipeDef& recipe) -{ - QStringList lines; - lines << QObject::tr("Recipe: %1") - .arg(QString::fromStdString(toDisplayName(recipe.id))); - - if (recipe.inputs.empty()) - { - lines << QObject::tr("Inputs: none"); - } - else - { - lines << QObject::tr("Inputs:"); - for (const RecipeIngredient& ingredient : recipe.inputs) - { - lines << itemLine(ingredient.item, ingredient.amount); - } - } - - lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds); - - if (!recipe.outputs.empty()) - { - lines << QObject::tr("Produces:"); - for (const RecipeOutput& output : recipe.outputs) - { - lines << itemLine(output.item, output.amount); - } - } - - return lines.join('\n'); -} diff --git a/src/ui/RecipeTooltip.h b/src/ui/RecipeTooltip.h deleted file mode 100644 index 5671637..0000000 --- a/src/ui/RecipeTooltip.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once - -#include - -struct RecipeDef; - -// Builds the recipe info tooltip text (REQ-UI-SELECT-TOOLTIP): the recipe name, -// each input item name and quantity, the completion time, and the produced -// output item name and quantity. Shared by the recipe-selection dialog and the -// schematic choice dialog's "Unlocks recipes:" list so both render identically. -QString buildRecipeTooltip(const RecipeDef& recipe); diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index 8d831bb..c1da16a 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -6,12 +6,35 @@ #include #include "DisplayName.h" -#include "RecipeTooltip.h" +#include "RecipeLineRow.h" #include "RecipesConfig.h" namespace { +std::vector toAmounts( + const std::vector& ingredients) +{ + std::vector amounts; + amounts.reserve(ingredients.size()); + for (const RecipeIngredient& ingredient : ingredients) + { + amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount }); + } + return amounts; +} + +std::vector toAmounts(const std::vector& outputs) +{ + std::vector amounts; + amounts.reserve(outputs.size()); + for (const RecipeOutput& output : outputs) + { + amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount }); + } + return amounts; +} + QString grantKindLabel(SchematicType type) { switch (type) @@ -28,7 +51,8 @@ QString grantKindLabel(SchematicType type) SchematicChoiceDialog::SchematicChoiceDialog( const std::vector& options, - const RecipesConfig& recipes, + const RecipesConfig& recipes, ItemIconCache* itemIcons, + BuildingIconCache* buildingIcons, QWidget* parent) : QDialog(parent) , m_chosenIndex(0) @@ -108,14 +132,23 @@ SchematicChoiceDialog::SchematicChoiceDialog( { for (const std::string& recipeId : option.newlyUnlockedRecipeIds) { - QLabel* recipeLabel = new QLabel( - QString::fromStdString(toDisplayName(recipeId)), card); - recipeLabel->setAlignment(Qt::AlignCenter); - if (const RecipeDef* def = recipes.findRecipeDef(recipeId)) - { - recipeLabel->setToolTip(buildRecipeTooltip(*def)); - } - cardLayout->addWidget(recipeLabel); + const RecipeDef* def = recipes.findRecipeDef(recipeId); + if (def == nullptr) { continue; } + + // The recipe drawn as it is everywhere else, led by the building + // that runs it and its name, so the line says everything there is + // to say about it and carries no tooltip (REQ-DEF-SCHEMATIC-DROP). + RecipeLineRow::Spec spec; + spec.building = def->building; + spec.name = QString::fromStdString(toDisplayName(def->id)); + spec.inputs = toAmounts(def->inputs); + spec.outputs = toAmounts(def->outputs); + spec.durationSeconds = def->durationSeconds; + + RecipeLineRow* line = + new RecipeLineRow(itemIcons, buildingIcons, card); + cardLayout->addWidget(line); + line->setLine(spec); } } } diff --git a/src/ui/SchematicChoiceDialog.h b/src/ui/SchematicChoiceDialog.h index 36a7446..745f97a 100644 --- a/src/ui/SchematicChoiceDialog.h +++ b/src/ui/SchematicChoiceDialog.h @@ -7,14 +7,19 @@ #include "SchematicChoiceOption.h" struct RecipesConfig; +class BuildingIconCache; +class ItemIconCache; class SchematicChoiceDialog : public QDialog { Q_OBJECT public: + // The icon caches draw the recipe lines of the "Unlocks recipes" list + // (REQ-DEF-SCHEMATIC-DROP, REQ-UI-ITEM-ICON, REQ-UI-BUILD-ICON). Not owned. SchematicChoiceDialog(const std::vector& options, - const RecipesConfig& recipes, + const RecipesConfig& recipes, ItemIconCache* itemIcons, + BuildingIconCache* buildingIcons, QWidget* parent = nullptr); int getChosenIndex() const; diff --git a/src/ui/ShipLayoutDialog.cpp b/src/ui/ShipLayoutDialog.cpp index 5551ad3..caf9b9a 100644 --- a/src/ui/ShipLayoutDialog.cpp +++ b/src/ui/ShipLayoutDialog.cpp @@ -5,11 +5,16 @@ #include #include "DisplayName.h" +#include "OptionButton.h" +#include "ProductionRules.h" +#include "RecipeLineRow.h" +#include "SectionBox.h" #include #include #include #include +#include #include #include #include @@ -22,6 +27,18 @@ namespace { +std::vector toAmounts( + const std::vector& ingredients) +{ + std::vector amounts; + amounts.reserve(ingredients.size()); + for (const RecipeIngredient& ingredient : ingredients) + { + amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount }); + } + return amounts; +} + const int kCellSize = 32; // Empty ship tiles pulse between this grey and white at 1 Hz while a module is @@ -401,9 +418,11 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config, std::vector& allBlueprints, std::set unlockedModuleIds, bool debugDraw, + ItemIconCache* itemIcons, QWidget* parent) : QDialog(parent) , m_config(config) + , m_itemIcons(itemIcons) , m_shipId(shipId) , m_unlockedModuleIds(std::move(unlockedModuleIds)) , m_rows(0) @@ -463,10 +482,22 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config, // Middle: three-column area (stats | module buttons | blueprints). QHBoxLayout* columnsLayout = new QHBoxLayout(); - // Left column: ship stats panel. + // Left column: ship stats panel, and beneath it what the layout being configured + // would cost to build (REQ-MOD-UI-DIALOG). The cost sits outside the stats panel + // because that widget is shared with the live ship card and the balancing tool, + // neither of which costs anything -- and the balancing tool is deliberately built + // without the item icons this line draws with. + QVBoxLayout* leftLayout = new QVBoxLayout(); m_statsPanel = new ShipStatsPanel(config, this); m_statsPanel->setDebugDrawEnabled(m_debugDraw); - columnsLayout->addWidget(m_statsPanel); + leftLayout->addWidget(m_statsPanel); + + m_buildCostSection = new SectionBox(tr("Build cost"), this); + m_buildCostLine = new RecipeLineRow(m_itemIcons, nullptr, m_buildCostSection); + m_buildCostSection->getContentLayout()->addWidget(m_buildCostLine); + leftLayout->addWidget(m_buildCostSection); + leftLayout->addStretch(1); + columnsLayout->addLayout(leftLayout); // Center column: module selection buttons. QVBoxLayout* centerLayout = new QVBoxLayout(); @@ -487,11 +518,34 @@ ShipLayoutDialog::ShipLayoutDialog(const GameConfig* config, m_moduleButtons.push_back(nullptr); continue; } - const QString label = QString::fromStdString(toDisplayName(def.id)) - + "\n" + QString::fromStdString(def.glyph); - QPushButton* btn = new QPushButton(label, this); + // The module's name over what it costs -- its materials and the production time + // it adds (REQ-MOD-UI-DIALOG). The glyph is how a module is identified on the + // layout grid, not here. Both children are transparent to the mouse so a click + // anywhere on the face still reaches the button. + OptionButton* btn = new OptionButton(this); btn->setCheckable(true); - btn->setFixedHeight(48); + + QVBoxLayout* face = new QVBoxLayout(btn); + face->setContentsMargins(8, 4, 8, 4); + face->setSpacing(1); + + QLabel* nameLabel = + new QLabel(QString::fromStdString(toDisplayName(def.id)), btn); + nameLabel->setAttribute(Qt::WA_TransparentForMouseEvents, true); + face->addWidget(nameLabel); + + RecipeLineRow::Spec cost; + cost.inputs = toAmounts(def.materials); + cost.durationSeconds = def.productionTimeSeconds; + cost.durationIsAddition = true; + + RecipeLineRow* costLine = new RecipeLineRow(m_itemIcons, nullptr, btn); + costLine->setAttribute(Qt::WA_TransparentForMouseEvents, true); + face->addWidget(costLine); + costLine->setLine(cost); + + // The config tooltip stays: it says what the module does, which the cost line + // does not (REQ-MOD-UI-MODULE-TOOLTIP). if (def.tooltip) { btn->setToolTip(QString::fromStdString(*def.tooltip)); @@ -731,6 +785,29 @@ void ShipLayoutDialog::updateGridWidget() void ShipLayoutDialog::updateStats() { m_statsPanel->refresh(m_shipId, m_placedModules); + + // What the layout as it currently stands would cost to build, so the price follows + // every placement and removal (REQ-MOD-UI-DIALOG). The sums come from the + // simulation's own rules rather than being added up a second time here + // (REQ-MOD-MATERIALS, REQ-MOD-PRODUCTION-TIME). + ShipLayoutConfig layout; + layout.placedModules = m_placedModules; + const std::map materials = + computeShipyardRequiredMaterials(*m_config, m_shipId, layout); + + RecipeLineRow::Spec cost; + cost.inputs.reserve(materials.size()); + for (const std::pair& entry : materials) + { + cost.inputs.push_back(RecipeLineRow::Amount{ entry.first, entry.second }); + } + cost.durationSeconds = + computeShipyardProductionTimeSeconds(*m_config, m_shipId, layout); + + m_buildCostLine->setLine(cost); + // A schematic costing no materials at all has no line to draw, and the section then + // stays out of the way. + m_buildCostSection->setVisible(!cost.inputs.empty()); } bool ShipLayoutDialog::canPlaceModule(const ModuleDef& def, QPoint position, diff --git a/src/ui/ShipLayoutDialog.h b/src/ui/ShipLayoutDialog.h index cdb3579..d1029a1 100644 --- a/src/ui/ShipLayoutDialog.h +++ b/src/ui/ShipLayoutDialog.h @@ -14,7 +14,10 @@ #include "ShipLayout.h" #include "ShipLayoutBlueprint.h" +class ItemIconCache; class QPushButton; +class RecipeLineRow; +class SectionBox; class ShipStatsPanel; class ShipLayoutDialog : public QDialog @@ -22,12 +25,15 @@ class ShipLayoutDialog : public QDialog Q_OBJECT public: + // itemIcons draws what each module costs and what the layout costs in total + // (REQ-MOD-UI-DIALOG, REQ-MOD-UI-STATS-PANEL). Not owned. ShipLayoutDialog(const GameConfig* config, const std::string& shipId, const ShipLayoutConfig& currentLayout, std::vector& allBlueprints, std::set unlockedModuleIds, bool debugDraw, + ItemIconCache* itemIcons, QWidget* parent = nullptr); std::optional getResult() const; @@ -60,6 +66,7 @@ private: void loadLayoutBlueprint(const std::vector& modules); const GameConfig* m_config; + ItemIconCache* m_itemIcons; std::string m_shipId; std::set m_unlockedModuleIds; std::vector m_shipLayout; @@ -81,6 +88,8 @@ private: QPushButton* m_removeButton; QWidget* m_gridWidget; ShipStatsPanel* m_statsPanel; + SectionBox* m_buildCostSection; + RecipeLineRow* m_buildCostLine; bool m_debugDraw; std::optional m_result; diff --git a/src/ui/selection/BufferedBuildingContent.cpp b/src/ui/selection/BufferedBuildingContent.cpp index b53a6e3..f514e59 100644 --- a/src/ui/selection/BufferedBuildingContent.cpp +++ b/src/ui/selection/BufferedBuildingContent.cpp @@ -10,7 +10,7 @@ #include "DisplayName.h" #include "FactoryQueries.h" #include "ProductionSection.h" -#include "RecipeSummaryRow.h" +#include "RecipeLineRow.h" #include "SectionBox.h" #include "SelectionNames.h" #include "Simulation.h" @@ -18,13 +18,13 @@ namespace { -std::vector toAmounts(const std::map& map) +std::vector toAmounts(const std::map& map) { - std::vector amounts; + std::vector amounts; amounts.reserve(map.size()); for (const std::pair& entry : map) { - amounts.push_back(RecipeSummaryRow::Amount{ entry.first, entry.second }); + amounts.push_back(RecipeLineRow::Amount{ entry.first, entry.second }); } return amounts; } @@ -66,17 +66,17 @@ BufferedBuildingContent::BufferedBuildingContent(const SelectionContext& context { // The summary is configuration -- what the building will do -- so it sits with the // selection control and is shown for a construction site too (REQ-UI-RECIPE-SUMMARY). - m_recipeSummary = new RecipeSummaryRow(context.itemIcons, this); + m_recipeSummary = new RecipeLineRow(context.itemIcons, context.buildingIcons, this); getConfigurationLayout()->addWidget(m_recipeSummary); m_inputSection = new SectionBox(tr("Input buffers"), this); - m_inputChips = new ItemChipRow(context.itemIcons, m_inputSection); + m_inputChips = new ItemChipRow(context, m_inputSection); m_inputSection->getContentLayout()->addWidget(m_inputChips); m_production = new ProductionSection(this); m_outputSection = new SectionBox(tr("Output buffer"), this); - m_outputChips = new ItemChipRow(context.itemIcons, m_outputSection); + m_outputChips = new ItemChipRow(context, m_outputSection); m_outputSection->getContentLayout()->addWidget(m_outputChips); // In the direction the materials flow: what goes in, what is being made of it, what @@ -98,10 +98,14 @@ void BufferedBuildingContent::refreshConfiguration() setBuildingIdentity(target.type, getBuildingTypeName(target.type)); + // The card already names the building in its header, so the summary states the cycle + // alone -- no building chip, no recipe name (REQ-UI-RECIPE-SUMMARY). const CycleInfo cycle = getCycleInfo(target); - m_recipeSummary->setSummary(toAmounts(cycle.perCycleInputs), - toAmounts(cycle.perCycleOutputs), - cycle.durationSeconds); + RecipeLineRow::Spec summary; + summary.inputs = toAmounts(cycle.perCycleInputs); + summary.outputs = toAmounts(cycle.perCycleOutputs); + summary.durationSeconds = cycle.durationSeconds; + m_recipeSummary->setLine(summary); refreshControls(target); } diff --git a/src/ui/selection/BufferedBuildingContent.h b/src/ui/selection/BufferedBuildingContent.h index 9fded6a..46b7d67 100644 --- a/src/ui/selection/BufferedBuildingContent.h +++ b/src/ui/selection/BufferedBuildingContent.h @@ -11,7 +11,7 @@ struct Building; struct BuildingTarget; class ProductionSection; -class RecipeSummaryRow; +class RecipeLineRow; class SectionBox; // Shared body of the four cards that show one building with buffers -- the Miner and @@ -74,7 +74,7 @@ private: BuildingId m_id; - RecipeSummaryRow* m_recipeSummary; + RecipeLineRow* m_recipeSummary; // Input buffers, production progress, output buffer -- in that order, so the card // reads the way the materials flow (REQ-UI-SINGLE-SELECTION). diff --git a/src/ui/selection/CMakeLists.txt b/src/ui/selection/CMakeLists.txt index fdb1129..75ab62b 100644 --- a/src/ui/selection/CMakeLists.txt +++ b/src/ui/selection/CMakeLists.txt @@ -14,7 +14,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.h ${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.h - ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.h ${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.h ${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.h @@ -49,7 +49,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/EmptyNote.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemChip.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ItemChipRow.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSummaryRow.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ItemTooltip.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ProductionSection.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionControl.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ClearBeltControl.cpp diff --git a/src/ui/selection/HqContent.cpp b/src/ui/selection/HqContent.cpp index 32190c3..3e0a737 100644 --- a/src/ui/selection/HqContent.cpp +++ b/src/ui/selection/HqContent.cpp @@ -23,7 +23,7 @@ HqContent::HqContent(const SelectionContext& context, const SelectionRequest& re m_hpBar = new BarRow(tr("HP"), this); m_stockSection = new SectionBox(tr("Building blocks"), this); - m_stockChips = new ItemChipRow(context.itemIcons, m_stockSection); + m_stockChips = new ItemChipRow(context, m_stockSection); m_stockSection->getContentLayout()->addWidget(m_stockChips); // HP first, as on every card that has it (REQ-UI-SELECTION-CARD, REQ-UI-HQ-PANEL). diff --git a/src/ui/selection/ItemChip.cpp b/src/ui/selection/ItemChip.cpp index 49f128b..11d3ebd 100644 --- a/src/ui/selection/ItemChip.cpp +++ b/src/ui/selection/ItemChip.cpp @@ -1,11 +1,15 @@ #include "ItemChip.h" +#include #include #include #include #include +#include #include +#include "ItemTooltip.h" + namespace { @@ -13,11 +17,18 @@ namespace const int kCountSizeRisePt = 2; const int kSubLineSizeDropPt = 1; +// How long the cursor rests on a chip before its tooltip appears, in milliseconds. +// Matches the feel of an ordinary tooltip, which this one replaces (REQ-UI-ITEM-TOOLTIP). +const int kHoverDelayMs = 500; + } // namespace -ItemChip::ItemChip(const QPixmap& icon, QWidget* parent) +ItemChip::ItemChip(const SelectionContext& context, const std::string& itemId, + const QPixmap& icon, QWidget* parent) : QWidget(parent) + , m_context(context) + , m_itemId(itemId) { // Its own boxed chrome, drawn with palette colors like the rest of the panel's // furniture rather than from visuals.toml, which is for world rendering. @@ -58,6 +69,11 @@ ItemChip::ItemChip(const QPixmap& icon, QWidget* parent) layout->addWidget(m_iconLabel); layout->addWidget(text); + + m_hoverTimer = new QTimer(this); + m_hoverTimer->setSingleShot(true); + m_hoverTimer->setInterval(kHoverDelayMs); + connect(m_hoverTimer, &QTimer::timeout, this, &ItemChip::showTooltip); } void ItemChip::setCount(const QString& count) @@ -70,3 +86,28 @@ void ItemChip::setSubLine(const QString& subLine) m_subLineLabel->setText(subLine); m_subLineLabel->setVisible(!subLine.isEmpty()); } + +void ItemChip::enterEvent(QEvent* event) +{ + m_hoverTimer->start(); + QWidget::enterEvent(event); +} + +void ItemChip::leaveEvent(QEvent* event) +{ + m_hoverTimer->stop(); + if (m_tooltip != nullptr) + { + m_tooltip->hide(); + } + QWidget::leaveEvent(event); +} + +void ItemChip::showTooltip() +{ + if (m_tooltip == nullptr) + { + m_tooltip = new ItemTooltip(m_context, m_itemId, this); + } + m_tooltip->showAt(QCursor::pos()); +} diff --git a/src/ui/selection/ItemChip.h b/src/ui/selection/ItemChip.h index 1bba711..5934107 100644 --- a/src/ui/selection/ItemChip.h +++ b/src/ui/selection/ItemChip.h @@ -1,28 +1,51 @@ #pragma once +#include + #include #include #include +#include "SelectionContext.h" + +class ItemTooltip; +class QEvent; class QLabel; +class QTimer; // One buffered item: its icon, its count in a larger type, and a sub-line beneath the // count (REQ-UI-SINGLE-SELECTION). Boxed so a row of them reads as separate quantities // rather than as a run of text. +// +// Hovering it explains where its item comes from (REQ-UI-ITEM-TOOLTIP). class ItemChip : public QWidget { Q_OBJECT public: // An empty icon leaves the icon off and the chip laid out around the text alone. - ItemChip(const QPixmap& icon, QWidget* parent = nullptr); + ItemChip(const SelectionContext& context, const std::string& itemId, + const QPixmap& icon, QWidget* parent = nullptr); void setCount(const QString& count); // Left empty for an item with nothing to say beneath its count. void setSubLine(const QString& subLine); +protected: + void enterEvent(QEvent* event) override; + void leaveEvent(QEvent* event) override; + private: - QLabel* m_iconLabel; - QLabel* m_countLabel; - QLabel* m_subLineLabel; + void showTooltip(); + + SelectionContext m_context; + std::string m_itemId; + QLabel* m_iconLabel; + QLabel* m_countLabel; + QLabel* m_subLineLabel; + // Held back so a cursor crossing the chip on its way elsewhere does not flash the + // tooltip, the way an ordinary tooltip is. + QTimer* m_hoverTimer; + // Built on the first hover, because most chips are never hovered at all. + ItemTooltip* m_tooltip = nullptr; }; diff --git a/src/ui/selection/ItemChipRow.cpp b/src/ui/selection/ItemChipRow.cpp index 471e039..cdd153f 100644 --- a/src/ui/selection/ItemChipRow.cpp +++ b/src/ui/selection/ItemChipRow.cpp @@ -19,9 +19,9 @@ const int kChipsPerRow = 2; } // namespace -ItemChipRow::ItemChipRow(ItemIconCache* itemIcons, QWidget* parent) +ItemChipRow::ItemChipRow(const SelectionContext& context, QWidget* parent) : QWidget(parent) - , m_itemIcons(itemIcons) + , m_context(context) { m_layout = new QGridLayout(this); m_layout->setContentsMargins(0, 0, 0, 0); @@ -68,9 +68,10 @@ void ItemChipRow::rebuildChips(const std::vector& entries) // The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file // is not an error: the chip then carries the square alone, and only an item with // no square either leaves the chip laid out around its count alone. - const QPixmap icon = m_itemIcons->getSquarePixmap(itemId, kChipIconSizePx); + const QPixmap icon = + m_context.itemIcons->getSquarePixmap(itemId, kChipIconSizePx); - ItemChip* chip = new ItemChip(icon, this); + ItemChip* chip = new ItemChip(m_context, itemId, icon, this); m_layout->addWidget(chip, static_cast(index) / kChipsPerRow, static_cast(index) % kChipsPerRow); // Shown right away, because the panel measures itself as soon as this returns. A diff --git a/src/ui/selection/ItemChipRow.h b/src/ui/selection/ItemChipRow.h index e1026ac..d2fda69 100644 --- a/src/ui/selection/ItemChipRow.h +++ b/src/ui/selection/ItemChipRow.h @@ -6,8 +6,9 @@ #include #include +#include "SelectionContext.h" + class ItemChip; -class ItemIconCache; class QGridLayout; // The buffered items of one building, each as a chip carrying the item's icon, its @@ -28,16 +29,18 @@ public: QString subLine; }; - // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with - // no icon file simply shows no icon, which is not an error. Not owned. - explicit ItemChipRow(ItemIconCache* itemIcons, QWidget* parent = nullptr); + // The context supplies the icon cache each chip draws its item with + // (REQ-UI-ITEM-ICON) -- an item with no icon file simply shows no icon, which is not + // an error -- and what a chip's tooltip reads to explain its item + // (REQ-UI-ITEM-TOOLTIP). + explicit ItemChipRow(const SelectionContext& context, QWidget* parent = nullptr); void setEntries(const std::vector& entries); private: void rebuildChips(const std::vector& entries); - ItemIconCache* m_itemIcons; + SelectionContext m_context; QGridLayout* m_layout; std::vector m_itemIds; // what the chips currently stand for std::vector m_chips; diff --git a/src/ui/selection/ItemTooltip.cpp b/src/ui/selection/ItemTooltip.cpp new file mode 100644 index 0000000..41ad63d --- /dev/null +++ b/src/ui/selection/ItemTooltip.cpp @@ -0,0 +1,163 @@ +#include "ItemTooltip.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DisplayName.h" +#include "GameConfig.h" +#include "ItemProducers.h" +#include "RecipeLineRow.h" +#include "RecipesConfig.h" +#include "Simulation.h" + +namespace +{ + +// Gap between the cursor and the tooltip's corner, in device-independent pixels. Wide +// enough that the cursor never covers the heading. +const int kCursorOffsetPx = 16; + +std::vector toAmounts( + const std::vector& ingredients) +{ + std::vector amounts; + amounts.reserve(ingredients.size()); + for (const RecipeIngredient& ingredient : ingredients) + { + amounts.push_back(RecipeLineRow::Amount{ ingredient.item, ingredient.amount }); + } + return amounts; +} + +std::vector toAmounts(const std::vector& outputs) +{ + std::vector amounts; + amounts.reserve(outputs.size()); + for (const RecipeOutput& output : outputs) + { + amounts.push_back(RecipeLineRow::Amount{ output.item, output.amount }); + } + return amounts; +} + +// The screen the cursor is on, falling back to the primary screen when the position is +// on none of them (a cursor between two screens of different heights). +QRect availableScreenRect(const QPoint& globalPos) +{ + const QScreen* screen = QGuiApplication::screenAt(globalPos); + if (screen == nullptr) + { + screen = QGuiApplication::primaryScreen(); + } + return (screen != nullptr) ? screen->availableGeometry() : QRect(); +} + +} // namespace + + +ItemTooltip::ItemTooltip(const SelectionContext& context, const std::string& itemId, + QWidget* parent) + : QFrame(parent, Qt::ToolTip) + , m_context(context) + , m_itemId(itemId) +{ + // The palette's tooltip colors behind a themed border, so it reads as a tooltip + // rather than as a stray window. + setFrameShape(QFrame::StyledPanel); + setAutoFillBackground(true); + QPalette tooltipPalette = palette(); + tooltipPalette.setColor(QPalette::Window, + tooltipPalette.color(QPalette::ToolTipBase)); + tooltipPalette.setColor(QPalette::WindowText, + tooltipPalette.color(QPalette::ToolTipText)); + setPalette(tooltipPalette); + + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(6, 4, 6, 4); + m_layout->setSpacing(2); +} + +void ItemTooltip::showAt(const QPoint& globalPos) +{ + rebuild(); + adjustSize(); + + // Kept on the screen the cursor is on: a tooltip for a chip near the right or + // bottom edge would otherwise be cut off. + const QRect screen = availableScreenRect(globalPos); + QPoint topLeft(globalPos.x() + kCursorOffsetPx, globalPos.y() + kCursorOffsetPx); + if (screen.isValid()) + { + topLeft.setX(qMax(screen.left(), + qMin(topLeft.x(), screen.right() - width() + 1))); + topLeft.setY(qMax(screen.top(), + qMin(topLeft.y(), screen.bottom() - height() + 1))); + } + + move(topLeft); + show(); +} + +void ItemTooltip::rebuild() +{ + while (QLayoutItem* item = m_layout->takeAt(0)) + { + if (item->widget()) + { + item->widget()->deleteLater(); + } + delete item; + } + + // The heading names the item. For an input chip this is the only place it is named + // at all, since such a chip carries a count and no name (REQ-UI-SINGLE-SELECTION). + QLabel* heading = + new QLabel(QString::fromStdString(toDisplayName(m_itemId)), this); + QFont headingFont = heading->font(); + headingFont.setBold(true); + heading->setFont(headingFont); + m_layout->addWidget(heading); + heading->show(); + + const ItemProduction production = + findItemProduction(m_itemId, *m_context.sim, *m_context.config); + + // Scrap is salvaged from debris rather than crafted (REQ-RES-DEBRIS-DROP), so it + // says where it comes from in place of the caption and lists nothing. + QLabel* caption = new QLabel(production.origin == ItemOrigin::Salvaged + ? tr("Salvaged from debris") + : tr("Produced by"), this); + m_layout->addWidget(caption); + caption->show(); + + if (production.origin == ItemOrigin::Undiscovered) + { + // Made somehow, but by nothing the player has unlocked -- named without being + // shown a path they cannot take yet (REQ-UI-ITEM-TOOLTIP). + QLabel* undiscovered = new QLabel(tr("Undiscovered"), this); + m_layout->addWidget(undiscovered); + undiscovered->show(); + return; + } + + for (const RecipeDef* recipe : production.recipes) + { + RecipeLineRow::Spec spec; + spec.building = recipe->building; + spec.name = QString::fromStdString(toDisplayName(recipe->id)); + spec.inputs = toAmounts(recipe->inputs); + spec.outputs = toAmounts(recipe->outputs); + spec.durationSeconds = recipe->durationSeconds; + + RecipeLineRow* line = + new RecipeLineRow(m_context.itemIcons, m_context.buildingIcons, this); + m_layout->addWidget(line); + line->setLine(spec); + } +} diff --git a/src/ui/selection/ItemTooltip.h b/src/ui/selection/ItemTooltip.h new file mode 100644 index 0000000..4a077f0 --- /dev/null +++ b/src/ui/selection/ItemTooltip.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include + +#include "SelectionContext.h" + +class QVBoxLayout; + +// The tooltip an item chip shows on hover: the item's name, and every way the player +// can currently produce it, each drawn as a recipe line (REQ-UI-ITEM-TOOLTIP). +// +// A window of its own rather than a Qt tooltip, because Qt's are text and this one is +// drawn -- item squares, building chips and all. It is a Qt::ToolTip window, so it +// floats above everything and takes no input. +class ItemTooltip : public QFrame +{ + Q_OBJECT + +public: + ItemTooltip(const SelectionContext& context, const std::string& itemId, + QWidget* parent = nullptr); + + // Rebuilds the content and shows the tooltip near the given screen position, kept + // inside the screen. Rebuilt on every show because what produces an item changes as + // the player unlocks recipes (REQ-LOCK-UI-RECIPE). + void showAt(const QPoint& globalPos); + +private: + void rebuild(); + + SelectionContext m_context; + std::string m_itemId; + QVBoxLayout* m_layout; +}; diff --git a/src/ui/selection/RecipeSelectionControl.cpp b/src/ui/selection/RecipeSelectionControl.cpp index df43d62..6a97a8d 100644 --- a/src/ui/selection/RecipeSelectionControl.cpp +++ b/src/ui/selection/RecipeSelectionControl.cpp @@ -45,13 +45,14 @@ void RecipeSelectionControl::setRecipeId(const std::string& recipeId) if (option.id == recipeId && !option.id.empty()) { m_button->setText(option.caption); - m_button->setToolTip(option.tooltip); return; } } + // No tooltip of its own: what the building has selected is drawn beneath the button + // as the recipe summary, and where its items come from is reachable by hovering the + // card's item chips (REQ-UI-SELECT-BUTTON, REQ-UI-ITEM-TOOLTIP). m_button->setText(m_type == BuildingType::Shipyard ? tr("Select schematic") : tr("Select recipe")); - m_button->setToolTip(QString()); } diff --git a/src/ui/selection/RecipeSummaryRow.cpp b/src/ui/selection/RecipeSummaryRow.cpp deleted file mode 100644 index b308097..0000000 --- a/src/ui/selection/RecipeSummaryRow.cpp +++ /dev/null @@ -1,123 +0,0 @@ -#include "RecipeSummaryRow.h" - -#include -#include -#include -#include - -#include "ItemIconCache.h" - -namespace -{ - -// Size the items' colored squares are drawn at on the summary line, in device-independent -// pixels. Larger than the artwork they carry, which the square insets (REQ-UI-ITEM-ICON). -const int kSummaryIconSizePx = 18; - -// Adds a freshly built label to the summary and shows it. -// -// The show is what makes it count: a widget created under an already-visible parent -// starts hidden, and a layout treats a hidden item as empty, so an unshown label would -// add nothing to the size hint the panel measures itself against as soon as this -// returns. -void addAndShow(QHBoxLayout* layout, QLabel* label) -{ - layout->addWidget(label); - label->show(); -} - -} // namespace - - -RecipeSummaryRow::RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent) - : QWidget(parent) - , m_itemIcons(itemIcons) -{ - m_layout = new QHBoxLayout(this); - m_layout->setContentsMargins(0, 0, 0, 0); - m_layout->setSpacing(4); - - hide(); -} - -void RecipeSummaryRow::setSummary(const std::vector& inputs, - const std::vector& outputs, - double durationSeconds) -{ - if (outputs.empty() && inputs.empty()) - { - // Forgotten as well as hidden, so re-selecting the same recipe later is seen as - // a change and shows the row again. - m_inputs.clear(); - m_outputs.clear(); - m_durationSeconds = -1.0; - hide(); - return; - } - if (inputs == m_inputs && outputs == m_outputs - && durationSeconds == m_durationSeconds) - { - return; - } - - m_inputs = inputs; - m_outputs = outputs; - m_durationSeconds = durationSeconds; - rebuild(inputs, outputs, durationSeconds); - show(); -} - -void RecipeSummaryRow::rebuild(const std::vector& inputs, - const std::vector& outputs, - double durationSeconds) -{ - while (QLayoutItem* item = m_layout->takeAt(0)) - { - if (item->widget()) - { - item->widget()->deleteLater(); - } - delete item; - } - - addAmounts(inputs); - if (!inputs.empty()) - { - const QChar rightArrow(0x2192); // U+2192 RIGHTWARDS ARROW - addAndShow(m_layout, new QLabel(QString(rightArrow), this)); - } - addAmounts(outputs); - - if (durationSeconds > 0.0) - { - const QChar middleDot(0x00B7); // U+00B7 MIDDLE DOT - addAndShow(m_layout, new QLabel( - QStringLiteral("%1 %2").arg(middleDot).arg( - tr("%1 s").arg(durationSeconds, 0, 'f', 1)), this)); - } - m_layout->addStretch(1); -} - -void RecipeSummaryRow::addAmounts(const std::vector& amounts) -{ - for (const Amount& entry : amounts) - { - // The item's icon on its colored square (REQ-UI-ITEM-ICON). A missing icon file - // is not an error: the square stands alone then, and only an item with no square - // either falls back to its id in text. - const QPixmap icon = - m_itemIcons->getSquarePixmap(entry.itemId, kSummaryIconSizePx); - if (!icon.isNull()) - { - QLabel* iconLabel = new QLabel(this); - iconLabel->setPixmap(icon); - addAndShow(m_layout, iconLabel); - } - else - { - addAndShow(m_layout, - new QLabel(QString::fromStdString(entry.itemId), this)); - } - addAndShow(m_layout, new QLabel(QString::number(entry.amount), this)); - } -} diff --git a/src/ui/selection/RecipeSummaryRow.h b/src/ui/selection/RecipeSummaryRow.h deleted file mode 100644 index 9d33105..0000000 --- a/src/ui/selection/RecipeSummaryRow.h +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include -#include - -#include - -class ItemIconCache; -class QHBoxLayout; - -// What one production cycle does, on a line: each input item with its per-cycle amount, -// an arrow, each output with its amount, and the cycle time -// (REQ-UI-RECIPE-SUMMARY). It restates the selected recipe without the player having to -// open the selection dialog, and it is the panel's only display of the cycle time. -class RecipeSummaryRow : public QWidget -{ - Q_OBJECT - -public: - struct Amount - { - std::string itemId; - int amount = 0; - - bool operator==(const Amount& other) const - { - return itemId == other.itemId && amount == other.amount; - } - }; - - // itemIcons is the window-wide per-item icon cache (REQ-UI-ITEM-ICON); an item with - // no icon file falls back to its id. Not owned. - explicit RecipeSummaryRow(ItemIconCache* itemIcons, QWidget* parent = nullptr); - - // Hides the row when there is nothing selected to produce, which is how a building - // with no recipe shows no summary at all. - void setSummary(const std::vector& inputs, - const std::vector& outputs, double durationSeconds); - -private: - void rebuild(const std::vector& inputs, const std::vector& outputs, - double durationSeconds); - void addAmounts(const std::vector& amounts); - - ItemIconCache* m_itemIcons; - QHBoxLayout* m_layout; - // What the row currently shows, so a refresh at tick rate rebuilds it only when the - // recipe actually changed. - std::vector m_inputs; - std::vector m_outputs; - double m_durationSeconds = -1.0; -}; diff --git a/src/ui/selection/ShipyardContent.cpp b/src/ui/selection/ShipyardContent.cpp index de399b4..289ea20 100644 --- a/src/ui/selection/ShipyardContent.cpp +++ b/src/ui/selection/ShipyardContent.cpp @@ -83,20 +83,11 @@ BufferedBuildingContent::CycleInfo ShipyardContent::getCycleInfo( *getContext().config, target.recipeId, target.shipLayout); // A shipyard's output is the ship itself, which never lands in an item buffer -- so - // the summary shows one ship rather than an item id. - info.durationSeconds = shipDef->schematic.productionTimeSeconds; - if (target.shipLayout.has_value()) - { - for (const PlacedModule& placed : target.shipLayout->placedModules) - { - const ModuleDef* moduleDef = - getContext().config->modules.findModuleDef(placed.moduleId); - if (moduleDef) - { - info.durationSeconds += moduleDef->productionTimeSeconds; - } - } - } + // the summary shows no output item at all. The time is the schematic's plus its + // modules', from the same place the layout dialog's build cost reads it + // (REQ-MOD-PRODUCTION-TIME). + info.durationSeconds = computeShipyardProductionTimeSeconds( + *getContext().config, target.recipeId, target.shipLayout); info.runsProduction = true; return info; }