diff --git a/docs/requirements.md b/docs/requirements.md index 8f932de..0fadc77 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -6,12 +6,12 @@ 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). +- **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. - **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. -- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs (identity labels; used in the world for building types not covered by an icon and as the fallback when an icon file is missing — REQ-UI-WORLD-ICON) for every building type, item type, ship schematic, and station type; a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors; and building status light colors (grey, green, red, and yellow fills plus the outline color, REQ-UI-STATUS-LIGHT). Loaded by the UI at startup; the simulation does not read it. +- **visuals.toml** — rendering-only config (not game parameters): fill and outline colors and glyphs (identity labels; used in the world for building types not covered by an icon and as the fallback when an icon file is missing — REQ-UI-WORLD-ICON) for every building type, item type, ship schematic, and station type; for items, the `fill` and `outline` colors are drawn as the item's belt/port square and serve as the fallback when the item's icon file is missing (REQ-UI-ITEM-ICON); a distinct beam color per tool type (weapon, repair, salvage) and beam width; overlay and toast colors; and building status light colors (grey, green, red, and yellow fills plus the outline color, REQ-UI-STATUS-LIGHT). Loaded by the UI at startup; the simulation does not read it. - **ship_layouts.toml** — named layout blueprints per ship type; written and read by the application to persist the layout blueprint panel (REQ-MOD-UI-BLUEPRINT-PANEL through REQ-MOD-UI-BLUEPRINT-FILE-LOAD). Not a game parameter file; the simulation does not read it. - REQ-CFG-RELOAD: When the player triggers a Restart (REQ-UI-GAME-MENU), all config files are reloaded from disk before the simulation is reset to its initial state. Formula strings are recompiled at that point. This allows config edits made while the application is running to take effect without a full application restart. @@ -91,7 +91,7 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro ## Game World - REQ-GW-COORDS: Tile coordinates are integer `(x, y)`. The origin `(0, 0)` is the first column of space — the tile immediately to the right of the asteroid's right edge at game start, at the top of the world. X grows right; Y grows down. All asteroid tiles have `x < 0`; asteroid left-expansions add tiles at increasingly negative X. The origin never shifts. -- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size; when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top). Items emerging from a building's output port are rendered by these same rules on that port's output belt (REQ-MAT-OUTPUT-EMERGE). +- REQ-GW-TILE-SIZE: Tiles are square. The tile size in pixels is derived automatically so that the world height (in tiles) exactly fills the game world view's height in pixels. Items on belts are rendered at half-tile size (drawn as an item icon, or a colored square as the fallback — REQ-UI-ITEM-ICON); when multiple items occupy the same tile they are spaced quarter-tile apart along the direction of travel and overlap, rendered in ascending order of progress — the least-progressed item is drawn first (bottom) and the furthest-progressed item is drawn last (on top). Items emerging from a building's output port are rendered by these same rules on that port's output belt (REQ-MAT-OUTPUT-EMERGE). - REQ-GW-BELT-CAPACITY: Belt tiles and tunnel entry/exit tiles each hold up to four items simultaneously, queued one behind the other in the direction of travel. Splitter tiles hold up to four items: two unassigned items (progress < 0.5, not yet routed to an output) and one item per output slot (progress ≥ 0.5, committed to a specific output direction). Output-slot items are rendered on top of unassigned items; when both output slots are occupied, their rendering order follows the clockwise port order starting from East. - REQ-GW-BELT-SPEED: Items on belts move at `world.toml [world].belt_speed_tiles_per_second` tiles per second (default 2). - REQ-GW-HEIGHT: The world height (in tiles) is read from `world.toml [world].height_tiles`. @@ -474,6 +474,7 @@ The screen is divided into two columns: a main column (75% width) containing the 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 with its **item icon** in place of the colored square of REQ-GW-TILE-SIZE. 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`). It fills the item's half-tile rect, keeping the size, spacing, and draw-order rules of REQ-GW-TILE-SIZE, and 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 falls back to its `visuals.toml` colored square (`fill` + `outline`); a missing icon is not an error, consistent with REQ-UI-BUILD-ICON. 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). @@ -533,6 +534,7 @@ The screen is divided into two columns: a main column (75% width) containing the - REQ-UI-SELECT-TOOLTIP: **Selection info tooltip.** Hovering an option button in the selection dialog (REQ-UI-SELECT-BUTTON), and hovering the selection button in the selected building panel when a selection is set, displays an info tooltip: - 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 per REQ-UI-ITEM-ICON. When the item has no icon file, the button falls back to the recipe/item name caption. The recipe name and details remain available on hover via the selection info tooltip (REQ-UI-SELECT-TOOLTIP). The `(None)` option keeps its text caption. This applies only to recipe options; the Shipyard schematic-selection dialog is unaffected and continues to show ship name captions. - REQ-UI-BELT-CLEAR: When one or more belt, splitter, tunnel entry, or tunnel exit tiles are selected, the panel shows a "Clear" button that removes all items from the selected tiles. Clearing a tunnel entry or exit also discards all items currently in transit through that tunnel (REQ-BLD-TUNNEL-TRANSIT). This can be used to resolve stalled belts, splitters, and tunnels. - REQ-UI-ENTITY-CLICK-SELECT: The player can click any ship (player or enemy) or any defence station (player or enemy) in the game world to select it. A plain click on a ship or defence station makes it the sole selection, clearing any previous selection. Ships and defence stations can be multi-selected — by Ctrl+clicking individual actors to add or remove them, or by box-drag (REQ-UI-MULTI-SELECT) — and can be selected together with debris and with one another in a single field selection (REQ-UI-SELECTION-CATEGORIES), freely mixing player and enemy actors. Actors cannot be selected together with buildings: selecting a ship or defence station clears any building selection, and selecting a building clears the actors (buildings win). Clicking a piece of debris adds to or establishes a field selection (REQ-UI-DEBRIS-CLICK-SELECT). Clicking empty world space (no building, ship, defence station, or piece of debris) clears the selection. - REQ-UI-SHIP-STATS-PANEL: When exactly one ship is selected (REQ-UI-ENTITY-CLICK-SELECT) and no debris is selected, the selected building panel shows a **ship stats panel**. (If debris is also selected, the panel shows the compact count summary instead, per REQ-UI-FIELD-MULTI-SELECTION.) The panel structure mirrors REQ-MOD-UI-STATS-PANEL but reflects the ship's actual live state: stats are computed from its installed modules per REQ-MOD-STAT-CALC. The panel always shows all hull stats: HP (current / maximum), max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, and max rotation speed. In addition, capability module summaries are shown conditioned on which module types are installed, using the same aggregation rules as REQ-MOD-UI-STATS-PANEL: weapons (combined DPS, maximum range), salvage (combined collection rate, maximum range), and repair (combined repair rate, maximum range), each section appearing only if at least one instance of that module type is installed. While debug draw mode is active (REQ-UI-DEBUG-DRAW), the panel additionally shows the ship's derived threat cost (REQ-MOD-THREAT). diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 2b9ea80..65da064 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -421,6 +421,14 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path) const toml::array& outputs = 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 = 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 c75e9ab..de484ea 100644 --- a/src/lib/config/RecipesConfig.h +++ b/src/lib/config/RecipesConfig.h @@ -32,6 +32,10 @@ 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/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index bfdf4bf..ab3b875 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -390,6 +390,39 @@ 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 33a09c8..4f521d8 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -15,6 +15,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h + ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.h PARENT_SCOPE ) @@ -34,5 +35,6 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ItemIconCache.cpp PARENT_SCOPE ) diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 83bde00..f5b57bc 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -49,6 +49,7 @@ #include "GameOverEvent.h" #include "HealthComponent.h" #include "HqProxyComponent.h" +#include "ItemIconCache.h" #include "PositionComponent.h" #include "RepairBehavior.h" #include "SalvageScrapBehavior.h" @@ -222,6 +223,11 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, loadBuildingIcons(configDir); + // Item icons live beside the config dir, mirroring the building icons + // (REQ-UI-ITEM-ICON, REQ-UI-WORLD-ICON). + m_itemIcons = std::make_unique(QDir::cleanPath( + QString::fromStdString(configDir) + "/../icons/items")); + m_renderTimer = new QTimer(this); m_renderTimer->setInterval(16); connect(m_renderTimer, &QTimer::timeout, this, &GameWorldView::onFrame); @@ -1597,23 +1603,15 @@ void GameWorldView::drawPortItems(QPainter& painter) } } - // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile filled square + outline. + // Shared with belt items (REQ-GW-TILE-SIZE): a half-tile item icon, or the + // colored-square fallback (REQ-UI-ITEM-ICON), via the same draw path. const std::function drawItem = [&](const ItemType& type, QPointF worldPos) { - const std::map::const_iterator it = - m_visuals->items.find(type.id); - if (it == m_visuals->items.end()) { return; } - const QPointF center = worldToWidget( QVector2D(static_cast(worldPos.x()), static_cast(worldPos.y()))); - const QRectF itemRect(center.x() - halfPx, center.y() - halfPx, - halfPx * 2, halfPx * 2); - painter.fillRect(itemRect, it->second.fill); - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(itemRect); + drawWorldItem(painter, type.id, center, halfPx); }; painter.save(); @@ -1623,6 +1621,33 @@ void GameWorldView::drawPortItems(QPainter& painter) painter.restore(); } +void GameWorldView::drawWorldItem(QPainter& painter, const std::string& itemId, + QPointF center, float halfPx) +{ + const QRectF itemRect(center.x() - halfPx, center.y() - halfPx, + halfPx * 2, halfPx * 2); + + // Prefer the item's icon (REQ-UI-ITEM-ICON); it is rasterized once at the current + // half-tile pixel size and cached, so this is a plain pixmap blit per frame. + if (m_itemIcons && m_itemIcons->hasIcon(itemId)) + { + int sizePx = qRound(static_cast(halfPx * 2.0f)); + if (sizePx < 1) { sizePx = 1; } + painter.drawPixmap(itemRect, m_itemIcons->getPixmap(itemId, sizePx), + QRectF(0, 0, sizePx, sizePx)); + return; + } + + // Fallback: the colored square from visuals.toml (REQ-GW-TILE-SIZE). + const std::map::const_iterator it = + m_visuals->items.find(itemId); + if (it == m_visuals->items.end()) { return; } + painter.fillRect(itemRect, it->second.fill); + painter.setPen(QPen(it->second.outline, 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(itemRect); +} + void GameWorldView::drawBeltItems(QPainter& painter) { const float halfPx = getTilePx() * 0.5f * 0.5f; @@ -1630,19 +1655,10 @@ void GameWorldView::drawBeltItems(QPainter& painter) m_sim->getBelts().forEachVisualItem(vr, [&](const VisualItem& vi) { - const std::map::const_iterator it = - m_visuals->items.find(vi.type.id); - if (it == m_visuals->items.end()) { return; } - const QPointF center = worldToWidget( QVector2D(static_cast(vi.worldPos.x()), static_cast(vi.worldPos.y()))); - const QRectF rect(center.x() - halfPx, center.y() - halfPx, - halfPx * 2, halfPx * 2); - painter.fillRect(rect, it->second.fill); - painter.setPen(QPen(it->second.outline, 1)); - painter.setBrush(Qt::NoBrush); - painter.drawRect(rect); + drawWorldItem(painter, vi.type.id, center, halfPx); }); } diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 84d2765..805b681 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,7 @@ struct Command; struct ParsedReplay; +class ItemIconCache; class ReplayPlayer; class Simulation; class QPainter; @@ -128,6 +130,12 @@ private: void drawCopyConfigFeedback(QPainter& painter); void drawStations(QPainter& painter); void drawBeltItems(QPainter& painter); + // Draws a single item centered at widget-space `center`, spanning `halfPx` in + // each direction (a half-tile). Uses the item's icon when one exists + // (REQ-UI-ITEM-ICON), otherwise falls back to the colored square from + // visuals.toml. Shared by drawBeltItems and drawPortItems. + void drawWorldItem(QPainter& painter, const std::string& itemId, + QPointF center, float halfPx); void drawDebris(QPainter& painter); void drawShips(QPainter& painter); void drawHpBar(QPainter& painter, qreal left, qreal top, qreal width, @@ -296,6 +304,10 @@ private: }; std::map m_buildingIcons; + // Per-item icon cache (REQ-UI-ITEM-ICON), loaded from /../icons/items. + // Shared draw path for belt and port items; pixmaps are cached per target size. + std::unique_ptr m_itemIcons; + // Funnels all player input into the single Simulation::apply chokepoint. CommandManager m_commandManager; // A Reset command was enqueued; reset the view after the next drain applies it. diff --git a/src/ui/ItemIconCache.cpp b/src/ui/ItemIconCache.cpp new file mode 100644 index 0000000..9981e22 --- /dev/null +++ b/src/ui/ItemIconCache.cpp @@ -0,0 +1,64 @@ +#include "ItemIconCache.h" + +#include +#include +#include + +ItemIconCache::ItemIconCache(const QString& iconDir) + : m_iconDir(iconDir) +{ +} + +const QByteArray& ItemIconCache::getSvg(const std::string& itemId) +{ + const std::map::const_iterator cached = + m_svgById.find(itemId); + if (cached != m_svgById.end()) + { + return cached->second; + } + + // An absent or unreadable file caches an empty byte array so it is not retried; + // a missing icon is not an error (REQ-UI-ITEM-ICON). + QByteArray svg; + QFile file(m_iconDir + "/" + QString::fromStdString(itemId) + ".svg"); + if (file.open(QIODevice::ReadOnly)) + { + svg = file.readAll(); + } + return m_svgById.emplace(itemId, std::move(svg)).first->second; +} + +bool ItemIconCache::hasIcon(const std::string& itemId) +{ + return !getSvg(itemId).isEmpty(); +} + +QPixmap ItemIconCache::getPixmap(const std::string& itemId, int sizePx) +{ + if (sizePx <= 0) + { + return QPixmap(); + } + + const std::pair key(itemId, sizePx); + const std::map, QPixmap>::const_iterator cached = + m_pixmapCache.find(key); + if (cached != m_pixmapCache.end()) + { + return cached->second; + } + + const QByteArray& svg = getSvg(itemId); + QPixmap pixmap; + if (!svg.isEmpty()) + { + QSvgRenderer renderer(svg); + pixmap = QPixmap(sizePx, sizePx); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + } + return m_pixmapCache.emplace(key, std::move(pixmap)).first->second; +} diff --git a/src/ui/ItemIconCache.h b/src/ui/ItemIconCache.h new file mode 100644 index 0000000..9eaf904 --- /dev/null +++ b/src/ui/ItemIconCache.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +// Rasterizes and caches per-item icon SVGs (REQ-UI-ITEM-ICON). Item icons are +// self-contained, full-color SVGs loaded from a directory, one file per item type +// named after the item's id (e.g. "iron_ore.svg"). Shared by the recipe-selection +// dialog (REQ-UI-RECIPE-ICON) and the game world's belt/port item rendering so the +// rasterization is not duplicated. +// +// A missing icon file is not an error: hasIcon() returns false for it and the caller +// falls back (a colored square in the world, a name caption in the dialog). +class ItemIconCache +{ +public: + // iconDir is the directory holding the ".svg" icon files + // (typically "/../icons/items"). + explicit ItemIconCache(const QString& iconDir); + + // True if an icon SVG file exists for the given item id. Loads the file's bytes + // on first query and remembers the result (including absence) so repeated calls + // are cheap. + bool hasIcon(const std::string& itemId); + + // Returns the item's icon rasterized to a transparent sizePx*sizePx pixmap, + // cached per (item id, size) so it is rendered once and reused across frames and + // only re-rasterized when the target size changes (REQ-UI-ITEM-ICON). Returns a + // null pixmap if the item has no icon file (callers should gate on hasIcon()). + QPixmap getPixmap(const std::string& itemId, int sizePx); + +private: + // Returns the raw SVG bytes for an item id, loading and caching them on first + // access. An absent file caches an empty QByteArray so it is not retried. + const QByteArray& getSvg(const std::string& itemId); + + QString m_iconDir; + std::map m_svgById; + std::map, QPixmap> m_pixmapCache; +}; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 14fe5f5..b846322 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -323,7 +323,11 @@ void MainWindow::handleEvent(std::shared_ptr command = std::make_shared(); diff --git a/src/ui/RecipeSelectionDialog.cpp b/src/ui/RecipeSelectionDialog.cpp index 4039639..d3fd19b 100644 --- a/src/ui/RecipeSelectionDialog.cpp +++ b/src/ui/RecipeSelectionDialog.cpp @@ -3,7 +3,10 @@ #include #include +#include +#include #include +#include #include #include @@ -11,6 +14,7 @@ #include "BuildingType.h" #include "DisplayName.h" #include "GameConfig.h" +#include "ItemIconCache.h" #include "RecipesConfig.h" #include "RecipeTooltip.h" #include "ShipsConfig.h" @@ -83,23 +87,38 @@ 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); options.push_back({recipe.id, QString::fromStdString(toDisplayName(recipe.id)), - buildRecipeTooltip(recipe)}); + buildRecipeTooltip(recipe), + iconItemId}); } } return options; } +namespace +{ + // On-screen size of a recipe option button's item icon (REQ-UI-RECIPE-ICON). + const QSize kOptionIconSize(32, 32); +} + RecipeSelectionDialog::RecipeSelectionDialog( const std::vector& options, - const QString& title, QWidget* parent) + const QString& title, const QString& itemIconDir, QWidget* parent) : QDialog(parent) { setWindowTitle(title); setModal(true); + ItemIconCache iconCache(itemIconDir); + QVBoxLayout* mainLayout = new QVBoxLayout(this); QGridLayout* grid = new QGridLayout(); mainLayout->addLayout(grid); @@ -109,7 +128,19 @@ RecipeSelectionDialog::RecipeSelectionDialog( { const RecipeSelectionOption& option = options[static_cast(i)]; - QPushButton* button = new QPushButton(option.caption, this); + QPushButton* button = new QPushButton(this); + // Icon-only when the produced item has an icon (REQ-UI-RECIPE-ICON); otherwise + // fall back to the caption. The name stays reachable via the tooltip. + if (!option.iconItemId.empty() && iconCache.hasIcon(option.iconItemId)) + { + button->setIcon(QIcon(iconCache.getPixmap( + option.iconItemId, kOptionIconSize.width()))); + button->setIconSize(kOptionIconSize); + } + else + { + button->setText(option.caption); + } if (!option.tooltip.isEmpty()) { button->setToolTip(option.tooltip); diff --git a/src/ui/RecipeSelectionDialog.h b/src/ui/RecipeSelectionDialog.h index 82b1e8f..6ad9914 100644 --- a/src/ui/RecipeSelectionDialog.h +++ b/src/ui/RecipeSelectionDialog.h @@ -20,6 +20,10 @@ 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; }; // Builds the lock-aware list of selectable options for a production building @@ -39,8 +43,12 @@ class RecipeSelectionDialog : public QDialog Q_OBJECT public: + // itemIconDir is the directory holding per-item icon SVGs (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. RecipeSelectionDialog(const std::vector& options, - const QString& title, QWidget* parent = nullptr); + const QString& title, const QString& itemIconDir, + QWidget* parent = nullptr); std::optional getChosenId() const;